本月我將回答更多的讀者問題。我將討論在普通用戶帳戶下運行的 Windows Communication Foundation Web 服務以及使用分持機密技術和雙重密鑰控制來保護信用卡數據等問題。
問:當我 以非管理員的身份運行簡單的 Windows® Communication Foundation 服務時,為何無法啟動?
答:首先我想說的是,很高興聽到您在普通用戶帳戶下測試代碼!這是開發人員不應 忽略的測試工作的一個重要方面。
現在,對於那些想徹底查清此類問題的 用戶,我推薦的第一個方法是啟動 Filemon 和 Regmon,查找文件打開操作失敗的記錄或查找注冊表項, 這兩個工具可以從 Sysinternals(最近被 Microsoft 收購)的網站 www.sysinternals.com 獲得。遺憾 的是,這些工具不能診斷所有可能導致此問題的症結所在。即使在 Windows Communication Foundation 中實施的偵聽 HTTP 通道這類最簡單的 Web 服務,默認情況下,也不能以普通用戶身份來運行,除非 Windows Communication Foundation 以 IIS 作為宿主。這是因為 Windows Communication Foundation HTTP 通道使用 HTTP.SYS 驅動程序安裝其偵聽程序,而且 HTTP.SYS 不允許非管理員在沒有管理員明確 授權的情況下,注冊偵聽程序。
為說明此問題,我構建了一個包含以下兩個文件的簡單 Web 服務 :該服務的源文件和應用程序配置文件。圖 1 顯示了該服務的代碼。在您以管理員身份啟動該服務時, 一切正常。但以普通用戶身份運行該服務時,則出現大問題!圖 2 顯示了在使用 Microsoft® .NET Framework 3.0 Beta 2 版時發生的異常情況。
Figure 2 Exception When Run as Normal User
System.ServiceModel.Diagnostics.CallbackException: A user callback threw an exception. Check the exception stack and inner exception to determine the callback that failed. ---> System.NullReferenceException: Object reference not set to an instance of an object. at System.ServiceModel.Channels.DatagramChannelDemuxer`2. OnListenerClosed(Object source, EventArgs args) at System.ServiceModel.Channels.CommunicationObject.OnClosed() --- End of inner exception stack trace --- at System.ServiceModel.Channels.CommunicationObject.OnClosed() at System.ServiceModel.Channels.CommunicationObject.Abort() at System.ServiceModel.Channels.ChannelListenerBase.OnAbort() at System.ServiceModel.Channels.SecurityChannelListener`1.OnAbort() at System.ServiceModel.Channels.CommunicationObject.Abort() at System.ServiceModel.Channels.ChannelListenerBase.OnAbort() at System.ServiceModel.Channels.CommunicationObject.Abort() at System.ServiceModel.Dispatcher.ChannelDispatcher.OnAbort() at System.ServiceModel.Channels.CommunicationObject.Abort() at System.ServiceModel.ServiceHostBase.OnAbort() at System.ServiceModel.Channels.CommunicationObject.Abort() at System.ServiceModel.Channels.CommunicationObject.Close( TimeSpan timeout) at System.ServiceModel.Channels.CommunicationObject.Close() at System.ServiceModel.Channels.CommunicationObject.Dispose() at Program.Main(String[] args) in C:\work\HelloService\Program.cs:line 24
Figure 1 Simple Web Service Source Code
//hello.cs - simple WCF Web service that listens on HTTP using System; using System.ServiceModel; [ServiceContract(Namespace = "http://example.org")] public interface IHello { [OperationContract] void SayHello(); } public class Hello : IHello { public void SayHello() { Console.WriteLine("Hello"); } } class ConsoleHost { static void Main(string[] args) { try { using (ServiceHost host = new ServiceHost(typeof(Hello), new Uri("http://localhost:8080/MyServices/"))) { host.Open(); Console.WriteLine( "Service is listening, press any key to quit."); Console.ReadKey(); } } catch (Exception x) { Console.WriteLine(x); } } } <!--app.config--> <configuration> <system.serviceModel> <services> <service name="Hello"> <endpoint address="Hello" binding="wsHttpBinding" contract="IHello"/> </service> </services> </system.serviceModel> </configuration>
正如我說過的,解決此問題的一個方法是以 IIS 作為宿主來提供您的 服務。但對於某些用戶,這不是一個可選的方法。例如,如果您在一個基於 Windows Forms 的應用程序 中,使用 Windows Communication Foundation 通過 HTTP Web 服務接收通知,則需要按照本專欄的指導 ,用其他方法處理此問題。對於 Windows NT® 服務,也會遇到同樣的問題。
這個問題不難解決 — 您只需管理員通過 HTTP.SYS 向您的應用程序授予所謂的命名空間保留項 。在進行這種授權時,管理員實際上是在說:“用戶可以偵聽使用我指定的 URL 前綴的 HTTP 請求 。”管理員可以將偵聽權限授予單個用戶或一個組。
您的安裝程序可以使用 HTTP API 以編 程方式授予命名空間保留項,不過遺憾的是,HTTP API 還沒有公用的 .NET 包裝程序。圖 3 顯示了一些 樣本代碼,如果您在 C# 環境下工作,則可以使用這些代碼來入門。這個特別的樣本代碼需要按名稱來傳 遞用戶或組賬戶,而且必須由本地管理員組的某個成員來執行。此代碼也使用 P/Invoke 調用 HTTP API ,這意味著就“公共語言運行庫”(Common Language Runtime,簡稱 CLR)中基於證據的安 全性而言,代碼需要在完全受信任的情況下運行。如果您是在 Microsoft Installer 中對其打包,則不 會出現問題。但如果您試圖在普通用戶帳戶下,通過一個以部分信任方式運行的下載應用程序來執行此操 作,則問題還是得不到解決。
Figure 3 Programatically Granting a Namespace Reservation
using System; using System.Xml; using System.Security.Principal; using System.Runtime.InteropServices; public class ReserveHttpNamespace { static void Main(string[] args) { if (args.Length != 2) { Console.WriteLine( "Usage: reserveHttpNamespace " + "prefix account"); return; } try { ModifyReservation(args[0], args[1], false); Console.WriteLine("Success!"); } catch (Exception x) { Console.WriteLine(x.Message); } } static void ModifyReservation( string urlPrefix, string accountName, bool removeReservation) { string sddl = createSddl(accountName); HTTP_SERVICE_CONFIG_URLACL_SET configInfo; configInfo.Key.UrlPrefix = urlPrefix; configInfo.Param.Sddl = sddl; HTTPAPI_VERSION httpApiVersion = new HTTPAPI_VERSION(1, 0); int errorCode = HttpInitialize(httpApiVersion, HTTP_INITIALIZE_CONFIG, IntPtr.Zero); if (0 != errorCode) throw getException("HttpInitialize", errorCode); try { // do our best to delete any existing ACL errorCode = HttpDeleteServiceConfigurationAcl( IntPtr.Zero, HttpServiceConfigUrlAclInfo, ref configInfo, Marshal.SizeOf( typeof(HTTP_SERVICE_CONFIG_URLACL_SET)), IntPtr.Zero); if (removeReservation) { if (0 != errorCode) throw getException( "HttpDeleteServiceConfigurationAcl", errorCode); return; } errorCode = HttpSetServiceConfigurationAcl( IntPtr.Zero, HttpServiceConfigUrlAclInfo, ref configInfo, Marshal.SizeOf( typeof(HTTP_SERVICE_CONFIG_URLACL_SET)), IntPtr.Zero); if (0 != errorCode) throw getException( "HttpSetServiceConfigurationAcl", errorCode); } finally { errorCode = HttpTerminate( HTTP_INITIALIZE_CONFIG, IntPtr.Zero); if (0 != errorCode) throw getException( "HttpTerminate",errorCode); } } static Exception getException(string fcn, int errorCode) { return new Exception( string.Format("{0} failed: {1}", fcn, getWin32ErrorMessage(errorCode))); } static string createSddl(string account) { string sid = new NTAccount(account).Translate( typeof(SecurityIdentifier)).ToString(); // DACL that allows generic execute for the user // specified by account. // See help for HTTP_SERVICE_CONFIG_URLACL_PARAM // for details on what this means. return string.Format("D:(A;;GX;;;{0})", sid); } static string getWin32ErrorMessage(int errorCode) { return Marshal.GetExceptionForHR(HRESULT_FROM_WIN32(errorCode)); } static int HRESULT_FROM_WIN32(int errorCode) { if (errorCode <= 0) return errorCode; return (int)((0x0000FFFFU & ((uint)errorCode)) | (7U << 16) | 0x80000000U); } // P/Invoke stubs from http.h const int HttpServiceConfigUrlAclInfo = 2; const int HTTP_INITIALIZE_CONFIG = 2; [StructLayout(LayoutKind.Sequential)] struct HTTPAPI_VERSION { public HTTPAPI_VERSION(short maj, short min) { Major = maj; Minor = min; } short Major; short Minor; } [StructLayout(LayoutKind.Sequential)] struct HTTP_SERVICE_CONFIG_URLACL_KEY { [MarshalAs(UnmanagedType.LPWStr)] public string UrlPrefix; } [StructLayout(LayoutKind.Sequential)] struct HTTP_SERVICE_CONFIG_URLACL_PARAM { [MarshalAs(UnmanagedType.LPWStr)] public string Sddl; } [StructLayout(LayoutKind.Sequential)] struct HTTP_SERVICE_CONFIG_URLACL_SET { public HTTP_SERVICE_CONFIG_URLACL_KEY Key; public HTTP_SERVICE_CONFIG_URLACL_PARAM Param; } [DllImport("httpapi.dll", ExactSpelling = true, EntryPoint = "HttpSetServiceConfiguration")] static extern int HttpSetServiceConfigurationAcl( IntPtr mustBeZero, int configID, [In] ref HTTP_SERVICE_CONFIG_URLACL_SET configInfo, int configInfoLength, IntPtr mustBeZero2); [DllImport("httpapi.dll", ExactSpelling = true, EntryPoint = "HttpDeleteServiceConfiguration")] static extern int HttpDeleteServiceConfigurationAcl( IntPtr mustBeZero, int configID, [In] ref HTTP_SERVICE_CONFIG_URLACL_SET configInfo, int configInfoLength, IntPtr mustBeZero2); [DllImport("httpapi.dll")] static extern int HttpInitialize( HTTPAPI_VERSION version, int flags, IntPtr mustBeZero); [DllImport("httpapi.dll")] static extern int HttpTerminate(int flags, IntPtr mustBeZero); }
您還可以使用一款名為 HTTPCFG.EXE 的工具,該工具可以在操作系統安裝磁盤的 SUPPORT 子 目錄下的各種支持工具中找到。您可以使用此工具列出現有的命名空間保留項,如:
httpcfg query urlacl
以下說明如何使用 HTTPCFG 為用戶帳戶創建命名空間保留項:
httpcfg set urlacl –u http://+:8080/MyServices/ -a D:(A;;GX;;;S-1-5-21- 1681502023-2202157333-1552196959-1028)
HTTPCFG 的 -a 參數代表 ACL(即訪問控制列表) ,您必須使用一種非常晦澀的名為“安全說明符定義語言”(Security Description Definition Language,簡稱 SDDL)的語言來指定該參數。這裡沒有足夠的篇幅解釋 SDDL,我把它留做 練習,請自行完成。(有關 SDDL 的詳細信息,請查看“安全說明符定義語言”。)您看到的 GX 指的是 GENERIC_EXECUTE 權限。如果您的目的是授予按前綴進行偵聽的權限,則從 HTTP.SYS 角度講 ,您應該授予此權限。
-u 參數為 URL 前綴,在授予權限時使用該參數來指定要向 HTTP.SYS 告 知的 URL 形式。URL 的格式如下。架構必須是小寫的 http 或 https。主機是區分大小寫的,且可以使 用 + 或 * 通配符(稍後我將詳細討論這一問題)。端口號是一個整數值,且是必需的,即使針對該方案 使用默認端口時也是如此。以下隨意列舉了一個區分大小寫的相對 URI(在前面的示例中是 /MyServices )。最後,不管您是否提供相對 URI,都需要以反斜槓結束字符串。
實際上,Windows Communication Foundation HTTP 通道注冊的是像前文所示的 URL 那樣形式的命名空間。這一操作是通 過使用 HTTP API 來完成的,關於向 HTTP 偵聽程序傳送請求,有一些 HTTP API 規則需要遵守。URL 中 的主機指定方式確定了,當發送來的請求與多個偵聽程序的前綴相匹配時,選擇偵聽程序的優先級順序。 例如,一個應用程序可能注冊了 foo.com:8080/,而另一個應用程序可能注冊了 foo.com:8080/MyServices/。一般來說,會優先考慮更具體明確的注冊前綴。不過,也可以使用通配符來 控制如何確定優先級別。應用程序可能會注冊 http://*:8080/。由於此偵聽程序使用了 * 通配符,因此 會接收到其他偵聽程序不希望接收的請求。換言之,* 屬於低優先級或弱通配符。另一方面,如果應用程 序注冊了 http://+:8080/,則會獲得最高的優先級,任何 8080 端口上的 HTTP 請求將不檢查其他具體 的注冊項,而直接轉到該應用程序。您可以通過“URL 前綴字符串”詳細了解有關 URL 前綴 格式方面的信息,但可以確定地說,如果想為 Windows Communication Foundation HTTP 通道成功地保 留命名空間前綴,您必須使用足以涵蓋 Windows Communication Foundation 要注冊的具體前綴的通用格 式。
如果您回頭查看圖 1,可以看到我向 ServiceHost 注冊的基址是 localhost:8080/MyServices/。遺憾的是,如果您的目的很直觀,只是想使用 HTTPCFG 來明確注冊此 URL 前綴,則無法達到目的。這是因為實際上,Windows Communication Foundation 要注冊的是一個強 通配符,它可以“撒下更大的網”,而這是具體的主機名注冊項所無法涵蓋的。您需要改用強 通配符語法,注冊 http://+:8080/MyServices/ 形式的 URL 前綴。不管是通過編程方式還是通過 HTTPCFG 工具完成這一操作,都要這樣做。
很多人在以管理員身份運行的情況下編寫代碼。但遺 憾的是,直到在非特權環境下部署代碼時,他們才會注意到這個問題,這也就是應該始終以非管理員身份 測試代碼的一個原因。但更好的做法是,您可以嘗試在以非管理員身份運行的情況下編寫代碼!我不會就 這個問題展開討論,但如果您有興趣了解以非管理員身份進行開發工作的詳細信息,請查看 Aaron Margosis 的博客,作者在其中專就此話題發表了多篇文章。
問:實施分持機密和雙重密鑰控制的最好方式是什麼?
答:很多網站接 受信用卡持卡人數據,包括信用卡卡號、帳單郵寄地址,等等。在 2004 年下半年,頒布了一個名為 “支付卡行業數據安全標准”(PCI-DSS) 的行業標准,對存儲、處理或傳輸此類數據的公司做 出了安全方面的規定。其中的 3.6.6 節提出了一個新要求,內容是:“分持機密和雙重密鑰控制( 即密鑰由兩、三個人分持,每個人只知道部分密鑰,所有人湊在一起才能重建整個密鑰)。 ”
答:很多網站接受信用卡持卡人數據,包括信用卡卡號、帳單郵寄地址,等等。在 2004 年下半年,頒布了一個名為“支付卡行業數據安全標准”(PCI-DSS) 的行業標准,對存儲、處 理或傳輸此類數據的公司做出了安全方面的規定。其中的 3.6.6 節提出了一個新要求,內容是:“ 分持機密和雙重密鑰控制(即密鑰由兩、三個人分持,每個人只知道部分密鑰,所有人湊在一起才能重建 整個密鑰)。”
對於在線交易系統,很難想象在每次需要處理信用卡請求時,讓兩、三個人站在一旁,分別鍵入密鑰 。但如果要長期存儲此類敏感數據,則這種思路就顯得合理多了。如果希望滿足這一要求,那麼如何著手 構建這樣一個系統?
在 N 個人之間拆分消息的最簡單方法是:得到 N-1 個隨機密鑰(每個密鑰 的長度必須與受保護的消息完全相同),並將這些密鑰的每個字節與消息中的相應字節進行 XOR(異或) 操作。完成所有操作後得到的數據作為第 N 個密鑰。然後可以將各個密鑰授予不同的人,並銷毀原始消 息。每個人所持有的密鑰看上去就像隨機亂碼(實際上,就是隨機數據)。重建原始消息的唯一方法是將 所有人重新召集在一起,收集他們的密鑰,並對其進行 XOR 計算。由於 XOR 的可交換屬性,計算的先後 順序是沒有關系的。只要將各個拆分密鑰重新混合,最終會找回原始消息。
這裡使用“消息 ”這一術語來指代需要保護的字節集。消息可以是字符串(如“Attack at dawn!”), 也可以是二進制數據(如用來保護永久性持卡人數據的加密密鑰)。
這一技術的安全性在理論上 是完美的。(我強調“理論上”是因為在實施中通常會有些問題!)如果一個人拒絕透露其密 鑰,即使其他人能夠將他們的密鑰集中在一起,所計算出來的消息也同他們各自持有的機密消息一樣,與 原始消息相差甚遠。但這也產生了以下缺陷:如果任何一個人遺失了他的密鑰,則永遠無法恢復原始消息 。如果這一點值得擔憂,那麼還有其他一些有關解決有限域中的等式的復雜得多的方法(請參閱 Bruce Schneier 的專著“應用加密”,第二版 (Applied Cryptography, Second Edition))。我將 在今後的專欄中詳細討論如何實施這些更加復雜的方案。現在,我展示一個使用 System.Security.Cryptography 解決密鑰拆分問題的簡單方案。
原型評測是一個分層的解決方案 ,其基礎是一個名為 SecretSplitter 的類中的兩個核心函數。這些函數使用了我在前文描述的技術來拆 分或連接消息。圖 4 顯示了這些函數的代碼。第一個函數 SplitSecret 接受兩個參數:一個字節數組和 一個整數,前者代表要拆分的消息,後者代表要拆分出的密鑰數量。JoinSecret 函數接受一個包含拆分 的密鑰的數組,並將這些密鑰連接起來,得到原始消息。如果不能提供最初拆分時的准確密鑰,您得到的 結果將與原始消息有所不同。
Figure 4 Splitting and Joining a Message
public static List<byte[]> SplitSecret(byte[] secret, int count) { if (null == secret || 0 == secret.Length) throw new ArgumentException("Non-empty value required", "secret"); if (count < 2) throw new ArgumentException("Must be greater than one", "count"); RNGCryptoServiceProvider random = new RNGCryptoServiceProvider(); // Get N-1 new secrets. List<byte[]> newSecrets = new List<byte[]>(count); for (int i = 0; i < count - 1; ++i) { byte[] newSecret = new byte[secret.Length]; random.GetBytes(newSecret); newSecrets.Add(newSecret); } // XOR all secrets into the existing one to get the final secret. byte[] finalSecret = (byte[])secret.Clone(); foreach (byte[] newSecret in newSecrets) { for (int i = 0; i < finalSecret.Length; ++i) { finalSecret[i] ^= newSecret[i]; } } newSecrets.Add(finalSecret); return newSecrets; } public static byte[] JoinSecret(List<byte[]> splitSecrets) { if (null == splitSecrets) throw new ArgumentNullException(); byte[] secret = null; foreach (byte[] splitSecret in splitSecrets) { if (null == splitSecret) throw new ArgumentNullException(); if (null == secret) { secret = (byte[])splitSecret.Clone(); continue; } if (splitSecret.Length != secret.Length) throw new ArgumentException( "All secrets must be of the same length"); // XOR all the split secrets together to get the original secret. for (int i = 0; i < secret.Length; ++i) { secret[i] ^= splitSecret[i]; } } return secret; }
請注意使用 RNGCryptoServiceProvider 類生成密鑰的方法。這種技術比使用 System.Random 一類的工具要好得多。RNGCryptoServiceProvider 是專門設計用來為加密操作生成隨機數據的,因此其 在不可預測性方面要強於由 System.Random 一類工具生成的數據流。它也是由計算機上多個平均信息量 的源自我生成的。
這兩個函數的頂部層是幫助器,由於對數據進行了 Base64 編碼,因此只需處 理字符串。代碼與我們的話題相關性不大,我在此就不做說明了。但由於我們經常要處理基於字符串的消 息和文本文件,因此代碼還是很有用的。所有功能都封裝在一個庫程序集中,我稱之為 SecretSplittingLibrary.dll。
在此庫的頂部,我構建了一個控制台應用程序,以此來演示密鑰 拆分在特定應用程序中是如何實現的。該應用程序名為 ThumbDriveSecretSplitter,包括兩個命令:一 個用來拆分消息,一個用來連接消息。
要拆分消息,可以運行以下的命令:
ThumbDriveSecretSplitter split "Attack at dawn!"
這將把 “Attack at dawn!”消息拆分到所有當前連到計算機的微型驅動器上。這至少是我最初的目 標。但當我嘗試這種做法時,發現了一個問題:應用程序在確定哪個卷代表可移動介質時,遇到了困難。 有一個微型驅動器在“磁盤管理器”中顯示為固定驅動器,我懷疑不只一個設備存在這種問題 。
所以,我回避了這一問題,給了程序一點提示。在該應用程序的配置文件中,我加入了一個包 含卷名稱的列表,應用程序會查找該列表,確定應使用哪些驅動器:
<appSettings> <add key="volumeNames" value="FatDrive;PSThumbDrv"/> </appSettings>
當我運行該命令時,程序會查找已配置列表中包括的驅動器,並通過將 Base64 編碼的拆分密鑰放在各個驅動器根目錄下名為“mysecret.txt”的文件中,在這些驅 動器之間拆分消息。這意味著可以在所有微型驅動器之間拆分消息,然後把驅動器發給負責保管密鑰的人 。
當您想恢復原始消息時,只需要讓這些人把他們手中的微型驅動器插入計算機,然後運行另一 個命令:
ThumbDriveSecretSplitter join
這樣就將各個密鑰組合起來,並輸出受保 護的消息。這就是使用 ThumbDriveSecretSplitter 的會話的輸出信息:
C:>ThumbDriveSecretSplitter.exe split "Attack at dawn!"
Writing E:\mysecret.txt
Writing F:\mysecret.txt
C:>ThumbDriveSecretSplitter.exe join
Reading E:\mysecret.txt
Reading F:\mysecret.txt
Joining secrets...
Attack at dawn!
當在實際系統中使用這一技術時,您可能不想將密鑰顯示出來,讓各方看到 — 這樣做首先就違 背了拆分消息的初衷。您可以改為使用程序來收集密鑰、計算密鑰並對其進行操作(通常是將其用作解密 持卡人敏感數據備份文件的密鑰)。
本文配套源碼:http://www.bianceng.net/dotnet/201212/747.htm