Windows2000 服务器端应用程序开发设计指南-安全连接

服务器软件必须以安全的方式通讯。不管您花多少时间及努力,把存取控制及安全性用在您系统的物件上;如果您的服务器没有确定两件事,则您的投入不会有任何意义:
  • 您的服务器必须确定客户端的身份,也就是说,您的服务器必须能够验证正在与它通讯之另一方的身份。
     
  • 您的服务器必须能够保证与客户端之间所传递的资讯未被第叁者篡改(或可能被检视)。
     

通常服务器软件在主控它的系统上拥有许多权利-假如您的服务器可以确定上述两件事,则您可以放心,它的权力不会被滥用,而且系统具有适当的安全性。

开发人员在Microsoft Windows 2000上执行服务的挑战,是使用一种与Windows安全性模组结合良好的方式验证客户端,以及使服务能够与这些客户端安全地通讯。认识这个挑战是本章的重点,但是我会先说明一些历史并解释某些术语的意义。

加密(Encryption)
 

加密是构成安全通讯的基础。它是个庞大的主题,本章和任何一本单独的书都无法完全地涵盖这部份。本节的目的是提供相关主题的背景资料,及说明某些基本的概念和重要术语。

两个以上的通讯方可使用加密的方式,安全地分享资讯。一般说来,当某一方没有金钥时,使用这种方式修改的资料就无法被复原成原始的状态。

金钥是一个数值,被加密演算法用来对资料做加密用;金钥也是被用来对资料解密的数值,修改后的资料形式即是 加密 。接收方可以使用这个金钥来复原资料(使资料可被理解),即 解密 程序。资料加密及解密的研究被称为 密码学 

密码学通常被用来在不安全的通讯媒介上,保证符合以下所列的一或叁个目标:

  •  私密性(Privacy) 除了预期通讯的双方外,没有人能理解通讯中的资料。
     
  •  验证性(Authentication) 表示您已经确认与您通讯之另一方的身份。
     
  •  完整性(Integrity) 收到的资料不会在传输中被第叁者篡改。
     

对称性金钥加密(Symmetric Key Encryption)
 

通常,当人们谈到加密通讯时,他们会提及在两方之间分享一个唯一的金钥,同样的金钥被用在资料的加密及解密,称为 对称性金钥加密 

对称性金钥加密只适合在两方分享一个秘密的合理环境中。随着本章的内容,您将看到使用对称性金钥加密的Microsoft Windows NT LAN Manager(NTLM)及Windows 2000使用的Kerberos安全性通讯协定(在本章稍后讨论)。

然而,有些情况下的对称性金钥加密并不符合标准。以下是其中一些原因:

  • 两方必须持有对称性金钥,并要求对方传递这些金钥。金钥的传递可能会造成安全性的漏洞。
     
  • 两方必须互相信任,以使用这个金钥。某一方能用金钥作的事,另一方也能。当一位已知的当事人与另一个不熟悉的当事人通讯时,这是重要的。
     

这些论点似乎在对方需要用安全方式与另一方通讯的环境中,产生无法克服的问题,例如一个企业内的区域网路(LAN)。然而,在已知的实体环境,例如公司网路或Windows网域中,对称性金钥加密可被用来产生非常优美与安全通讯的解决办法。您将在本章稍后的Kerberos主题讨论中,看到关于这方面的范例。

非对称性(Asymmetric)或公开金钥(Public Key)加密
 

Internet建立了一个安全通讯的环境,甚至在一个或多个通讯当事人彼此间没有事先了解的情形下也是如此。在Internet的环境中,如果每一方皆分享一个秘密金钥,是无法实行的。这就是 非对称性  公开金钥 加密杰出的地方。

公开金钥加密利用两个金钥,分别是用来分享的 公开金钥 ,及必须保密的 私密金钥 。经由成对的公开金钥加密后的资料只能使用私密金钥解密;相反地,用成对的私密金钥加密的资料只能用公开金钥解密。您不能从公开金钥中取得成对的私密金钥。然而,如果使用暴力攻击(brute-force)技巧,则可以列举所有可能的私密金钥,直到找出与公开金钥相配的一个。当金钥的宽度增加,这个方法会变得无法计算且更加困难。

如您所见,像这样的系统有许多可能性。您可以发布您的公开金钥,以使想要与您通讯的代理程序用您的公开金钥加密它的资料,此时只有您可以阅读资料,因为只有您持有私密金钥。

不幸的是,使用非对称性之成对的(公开/私密)金钥加密及解密资料的演算法速度比使用对称性金钥加密的演算法慢。因为这样,在使用非对称性金钥加密大量的资料时,通常是不适当的。

为了避开这个问题,许多通讯协定利用公开金钥加密方式来传递对称性金钥。然后在其馀的会谈或「工作阶段」期间使用对称性金钥加密。这样一来,在享有对称性金钥加密效率的同时,也能实现公开金钥加密的优点。

数位凭证(Digital Certificate)
 

 数位凭证  凭证 是在一对非对称性金钥中封装或发行公开金钥的方法。数位凭证也可以包含例如金钥拥有者及凭证用法的附加资讯。


说明

不要将非对称性成对金钥中的私密金钥封装在凭证里,因为它不应该被发行。应由其拥有者保存。


假如您能验证凭证的有效性,就可以使用凭证内持有的公开金钥将来自凭证拥有者的资料加以解密。用这种方法,您可以信任这个资料及资料的来源。同样地,假如您信任凭证的有效性,则您可以使用在凭证中找到的公开金钥,以对资料做加密,只有合法的凭证拥有者可以将资料解密。当您在某个安全的商业Web网站上,例如Amazon.com或Ebay.com,使用您的Web浏览器传递信用卡资讯时,就会使用到这类的技术。

一个重要的部份是利用您软件的能力去验证凭证的有效性,并因此信任公开金钥的有效性。这个实体即是凭证授权单位,或称CA。

凭证由CA发行,它用自己的公开金钥签署这个凭证。由于对凭证的签署,CA保证凭证中所持有的资讯是正确的,包括公开金钥。就这个CA而言,如果您持有CA的公开金钥(我们马上会讨论到更多细节),则表示您可以确定此凭证的签章未被篡改。

您所信任的实体是凭证授权单位,它发行讨论中所提的凭证。客户端经由持有CA公开金钥的副本来信任凭证授权单位,并且用它来验证收到凭证的签章。一旦客户端验证这个签章,它可以选择在信任凭证中找到的资讯是否以它与CA的信任关系为基础。

有两个凭证授权单位的例子,包括VeriSign,可以在 

依您所需的情形,您可能会发现,使用Microsoft Certificate Services或类似的软件来建立您自己的CA会成为您的优势。这样做可让您在您自己的企业或Internet环境中建立及分配凭证。潜在地增加凭证的弹性可以替您省下凭证许可的费用。

我无法在本章全面讨论凭证的内容,但是它是一个值得注意的重要技术。当我们在讨论安全通讯端阶层(Secure Sockets Layer,SSL)安全性通讯协定时,本章将再次讨论凭证的内容。有关这个主题的更广泛讨论,我建议您参阅《Platform SDK》文件或在World Wide Web上搜寻这个主题。

安全性通讯协定
 

通讯协定已经被定义用来初始及实作安全的通讯。不同的通讯协定可以符合不同的需求,而我们将在本章讨论叁种通讯协定:Kerberos、NTLM及SSL。选择这些通讯协定是因为Windows 2000为它们提供了特殊的支援,以及它们在现存环境中(如Internet)的重要性。

安全性通讯协定主要被设计于以定义好的方式使用加密技术和网路通讯技术,实作一个或叁个本章前面所提的目标:即私密性、验证性及完整性。这叁个我们将讨论的通讯协定,以不同的等级达成这些目标。某些通讯协定的额外特色是委派及彼此认证。

Kerberos及NTLM安全性通讯协定是Windows 2000安全性不可或缺的部分。就其本身而言,这些通讯协定可以被直接用来验证网域中的使用者。服务器可以使用Kerberos及NTLM安全性通讯协定撷取客户端的存取权杖(Token)(有关权杖的细节,请参阅 

SSL是一个安全性通讯协定,它不像其他通讯协定一样与Windows安全性模组紧密结合。SSL是一个以凭证为基础的通讯协定,Windows必须在服务器可以执行模拟(Impersonate)之前即做到凭证资讯的对应(模拟的内容在第十一章中有详细的讨论)。

NTLM
 

NTLM是Windows家族现行的安全性通讯协定。它可以在Windows 2000、Windows NT及Windows 95/98中使用。尽管Kerberos比NTLM提供了某些值得注意的改进,您仍旧必须了解NTLM的内容。

NTLM通讯协定相当易懂,而且是以挑战/回应(Challenge/Response)顺序为基础。说明于图12-1中,其执行步骤如下所示:

    •  步骤一 客户端传送其使用者名称及网域名称到服务器。
       
    •  步骤二 服务器转送这些资讯到网域控制站(Domain Controller,DC)。
       
    •  步骤叁 DC产生一个 挑战(Challenge) ,它是用客户端的密码(只有客户端及DC知道)随机地产生及建立的。
       
    •  步骤四 DC传送挑战到服务器。
       
    •  步骤五 服务器转送挑战到客户端。
       
    •  步骤六 客户端使用它的密码检查挑战的内容,并且对挑战执行 已知的修改(known modification) ,产生 回应(Response) 。已知的修改由NTLM通讯协定定义。
       
    •  步骤七 客户端把回应传送回服务器。
       
    •  步骤八 服务器转送回应到DC。
       
    •  步骤九 DC使用经由已知的修改修正过之原始挑战检查及验证回应的内容。客户端现在已经被认证。
       
  •  步骤十 DC通知服务器客户端已经被认证。

     

     图12-1 NTLM验证

如图12-1所示,由NTLM产生的网路流量是值得注意的,而且服务器承担着大量的责任,即使它主要是在客户端及网域控制站(DC)之间扮演Proxy的角色。但是这个通讯协定的确是完成了这个工作。

Kerberos
 

如同上一节所提到的,NTLM是所有Microsoft桌上型电脑作业系统都支援的唯一一个安全性通讯协定。由于这个原因,它在企业的服务器开发中仍然扮演者非常重要的角色。Kerberos是Windows 2000的闪亮之星,它提供了很多极好的特色:

  •  彼此验证(Mutual authentication) 除了更多经由服务的常见客户端验证外,Kerberos提供一种让客户端也能确认服务器身分的机制。这种验证有效地经由Kerberos而实现,不需要客户端做第二次的验证。
     
  •  委派(Delegation) 服务器可以使用它的客户端凭证与代表客户端的第二层服务器联系。透过委派过程,第二层服务器可以将它的使用者环境视为客户端而验证服务器。
     
  •  有效的验证 在Kerberos验证的行程中要求最小的通讯行程,而且只有单一的通讯行程必须由服务器执行(或者假如客户端是彼此验证的,则为两个通讯行程)。这样一来,可从服务器中移除许多负载,并把责任转移到客户端。
     
  •  Kerberos是个标准 Kerberos验证系统是MIT设计之Athena专案的一部分。Windows 2000实作了RFC 1510中定义的Kerberos V5.0。就本身而言,Windows 2000能够与使用Kerberos通讯的其他作业系统互相运作。
     

Kerberos是Greek神话中看守地狱入口之叁头兽的名字。Kerberos通讯协定以这个神话动物的名字命名,因为它也有叁个元件:

  •  客户端 这是初始验证会谈的实体或原则。
     
  •  服务器 这是客户端想要通讯的实体或原则。
     
  •  金钥发行中心(Key Distribution Center,KDC) 一个客户端及服务器所信任的实体,并为它们管理验证。
     

每个实体在Kerberos规格中扮演着重要的角色。以下的Kerberos叙述将为您解释一些重要的术语及概念,但是在应用程序中使用Kerberos之前,并不需要完全了解其内容。

Kerberos验证
 

Kerberos使用对称性金钥加密的方法。服务器使用Kerberos验证客户端的解密封包,即是 授权者(authenticator) ,它由客户端传送并且包含加密客户端身分识别及时戳(Timestamp)的副本(用来避免透过线路的授权者复制及重复使用它)。只有客户端及服务器能够用这个金钥加密或解密此授权者,它保证是机密的,而且也只有它们知道。这个秘密金钥为 工作阶段金钥(session key) ,这个工作阶段金钥也是客户端及服务器在工作阶段期间继续通讯时用来加密资料的金钥。

Kerberos验证到目前为止是相当简单的,但是如同您所猜想的,Kerberos真正的魅力是在协商 工作阶段金钥(session key) 的部份。工作阶段金钥必须是唯一的,而且是一个只有通讯实体才知道的机密-即客户端及服务器。以下是一些较有争议的问题:

  • 由谁建立工作阶段金钥?
     
  • 它如何以安全的方式传递到客户端及服务器?
     

我将每次讨论一个步骤,回答这些问题的程序,但是您要先了解Kerberos协定的许可证(Ticket)概念。

 许可证(Ticket) 许可证 是个资讯封包,该资讯的内容由客户端持有,包含可让服务器验证之客户端资讯。这个封包被命名为许可证,因为它的资讯是客户端「使用」服务器的「许可证」。

这里是包含在许可证内的一些重要项目:

  • 持有许可证的客户端身分识别。这是使用者名称及Windows 2000的网域资讯。
     
  • 用服务器的密码将工作阶段金钥加密。这个私密金钥用来加密及解密任何客户端及服务器间通讯的资料
     
  • 建立时间及到期时间。
     
  • 附加的资讯及标记。
     

许可证由金钥发行中心或KDC产生。在许可证内找到的秘密工作阶段金钥适用于服务器的密码加密,只有KDC及服务器知道这个密码。尽管KDC将许可证授予客户端,但是许可证的加密部分对客户端来说并非透明的,而且只由要求的服务器使用。

客户端要求与特定服务器使用的许可证,然后KDC会授予这些许可证。与服务器的验证通讯可以使用及重复使用许可证,直到时间届满为止,那时候,就必须从KDC授予客户端新的许可证。

因为使用服务器的密码对工作阶段金钥加密,所以这个金钥只能由服务器使用。此外,许可证持有者身分识别的加密以及明文版本都被包含在许可证上。这可让任何人知道许可证的持有者,而加密的副本则可让服务器担保自从建立许可证以来,其持有者没有改变。

许可证的有效期间定义了与服务器的工作阶段。这个工作阶段会持续到许可证届期或客户端丢弃此许可证为止。服务器不会储存这个许可证,所以验证(从服务器的观点)并没有状态(Stateless)。

现在我要叙述一个非常重要的工作阶段,即是客户端与KDC的工作阶段。这也是客户端必须通过叁个阶段中的第一个阶段,用来向服务器验证它自己。图12-2中显示了使用Kerberos通讯协定之开始到结束的整个验证程序。


 图12-2 的内容,与服务器的通讯直到第叁阶段才开始。服务器及KDC间从未通讯,这是现行NTLM验证方案值得注意及改进的地方。

 图12-2 Kerberos协定验证程序

 ticket-granting ticket(第一阶段) 如前所述,KDC建立许可证并把它们传递到要求的客户端中。然而,KDC本身是个服务器,而客户端必须维持一个与KDC一起使用的许可证。因为客户端会使用KDC撷取与系统中其他服务器一起使用的许可证,所以客户端与KDC一起使用的许可证被称为ticket-granting ticket或TGT。这是客户端从KDC要求的第一个许可证,而且它定义了一个与KDC一起的工作阶段。以下是取得TGT的步骤:

  •  步骤一 客户端传送一个ticket-granting ticket要求给KDC(Microsoft已经定义事先向KDC要求验证使用者的额外资料。这个资讯不是Kerberos定义的一部分,而且它对我们的讨论并不重要)。
     
  •  步骤二 KDC传送带有经由客户端密码加密之私密金钥的ticket-granting ticket到客户端。这个私密金钥是未来与KDC通讯的工作阶段金钥。
     

现在客户端已经持有工作阶段金钥及许可证,它可以在未来对许可证的要求中与KDC一起使用。传回客户端的工作阶段金钥已使用客户端的密码加密过。假如客户端在它的身分识别上作假,则它将无法解开工作阶段金钥,而且ticket-granting ticket对它来说是无用的。这是因为所有未来将与KDC的通讯中都会使用工作阶段金钥。

跟所有的许可证一样,ticket-granting ticket有一个经由服务器密码及到期资讯加密的工作阶段金钥。只要客户端持有KDC的未到期TGT,它就能参加服务器的工作阶段。

 要求一个许可证(第二阶段) 当客户端想要初始与服务器一起验证的工作阶段时,首先必须从KDC要求这个工作阶段的许可证。在它能这样做之前,必须持有与KDC一起使用的ticket-granting ticket。这已经在第一阶段中建立。以下为客户端要求与服务器一起使用之许可证所采取的步骤:

  •  步骤一 客户端传送请求许可证到KDC。这个请求包括以下资讯:
     
    • 授权者(Authenticator)(客户端的身分识别及时戳),它被客户端为了要与KDC通讯而持有的工作阶段金钥所加密。
       
    • ticket-granting ticket。
       
    • 客户端请求许可证的服务器。
       
  •  步骤二 KDC经由对内部持有之工作阶段金钥的解密而开启TGT(使用它自己的秘密密码)。KDC用这个工作阶段金钥对授权者解密。假如解密成功,表示客户端被KDC认证,然后KDC会为客户端建立所要求的许可证。
     
  •  步骤叁 KDC传送所要求的许可证回到客户端(许可证包含只有KDC及服务器知道的密码所加密的工作阶段金钥副本)。KDC也传递经由与客户端TGT关联的秘密工作阶段金钥加密的工作阶段金钥(与服务器一起使用)到客户端。
     

这个时候,客户端已经持有与要求服务器验证的许可证。客户端自己也持有与服务器通讯时使用的工作阶段金钥副本。

 与服务器一起验证(第叁阶段) 最后,只有客户端持有与服务器验证的许可证时,客户端才会开始与服务器通讯。这个许可证在第二阶段被授予。以下为客户端与服务器验证时所采取的步骤:

  •  步骤一 客户端传送验证的请求到服务器,这个请求包括了以下的资讯:
     
    • 授权者(客户端的身分识别及时戳),它被客户端为了这个工作阶段而持有的工作阶段金钥所加密。
       
    • 客户端为这个工作阶段所持有的许可证。
       
  •  步骤二 服务器经由使用私密密码的解密而打开许可证,其中持有工作阶段金钥。然后服务器会使用工作阶段金钥将包括在请求里的授权者解密。假如此解密产生作用,则表示客户端已经通过服务器的验证。
     
  •  步骤叁 假如客户端已经请求彼此验证,服务器会传送使用工作阶段金钥所加密的授权者到客户端。服务器不能对授权者做加密的动作,除非它持有工作阶段金钥,证明它就是所请求的服务器。
     

此时,客户端及服务器可以使用它们分享的工作阶段金钥对网路上的资料加密,开始通讯。

跟KDC一样,服务器不储存它的许可证,而这样的使用方法即是没有状态的(Stateless)验证。这意味着客户端想要与服务器通讯的每个连续时间都需要执行第叁阶段。

注意 

如前所述,Kerberos通讯协定涵盖的范围并不包含每个通讯协定的细节。然而,此叙述提供了一些观点,当我们开始讨论使用安全性通讯协定的程序设计时,您会发现它是很有帮助的。

Windows 2000开发人员服务
 

在讨论程序代码之前,我想先提Windows 2000所提供用来帮助开发人员处理密码学、凭证及安全性通讯协定的特别APIs,或函数群组的部份。

CryptoAPI概述
 

从我们先前就密码学所进行的讨论中可以想像,使用加密的软件必须考虑一些细节。它至少必须能够建立及维持金钥。而且它也要能够使用这些金钥来对资料加密及解密。

假如要求使用非对称性金钥之加密,软件也必须能够建立及管理公开/私密金钥。如前所述,凭证已经成为公开金钥管理的一个重要部分,而软件也可能必须负责管理凭证。

幸运地是,CryptoAPI(也被称为CAPI)提供了所有管理金钥及凭证,和加密及解密资料所需的功能。假如您的客户端及服务器软件要使用您自己的安全性通讯协定时,CryptoAPI会很有帮助。

在您要使用现有的安全性通讯协定,而不想担心密码学及金钥管理的细节时,应该感谢Security Support Provider Interface(SSPI)。


说明

本章稍后的〈CryptoAPI〉一节将说明用来做凭证管理的CryptoAPI函数。有关CryptoAPI的更多资讯,请参阅《Platform SDK》文件。


Security Support Provider Interface(SSPI)
 

Windows能够使用不同的安全性通讯协定而通讯,例如我们已经讨论过的NTLM及Kerberos(在本章稍后将会讨论SSL)。这些通讯协定的系统内部实作是模组单元,即Security Support Providers,或SSPs(一个单独的SSP有时候被称为 安全性封包(security package) ,或 封包(package) )。这个实作可让系统以标准化的方法使用多种安全性通讯协定,也可以让Windows的开发人员建立新的SSPs,并把它们并入系统中(事实上,它也能让协力厂商建立与Windows一起使用的SSPs)。

您可能会猜测系统也为SSPs提供了程序设计介面,它可让您的软件使用系统所提供的安全性通讯协定。这个介面被称为Security Support Provider Interface,或是SSPI。SSPI是个非常强大的一组函数。以下是它所提供的一些特色:

  • 通讯传输独立。
     
  • 多种SSPs的一般介面。
     
  • 验证(包括模拟)。
     
  • 讯息私密性(加密)。
     
  • 讯息完整性(签章)。
     

每个特色都由SSPI函数管理,所以您的程序代码不必处理加密及实作安全性通讯协定的细节。您的程序代码可以做它所擅长的事,从客户端来回地传递资讯以及执行代表它们的任务。

在讨论SSPI提供的协定之前,我想要先针对传输独立作些说明。SSPI建立从客户端传送到服务器的 安全性二进位大型物件(Blobs) ,反之亦然。SSPI也将取得资料并修改它,建立服务器及客户端软件来回间传送之加密或签章的安全性Blobs。您可以传送由SSPI函数传回的Blob,接收端会传递Blob到它的SSPI函数中,而函数负责转译资讯。SSPI函数并不会实际传递这个资料,而是透过软件来传送。

SSPI的传输独立给予软件极大的弹性。由于软件的工作是传递SSPI所建立的资料,您可以使用任何想要的通讯机制,包括通讯端、命名管道、NetBEUI及IPX/SPX。实际上,SSPI的其中一位设计者想要指出您可以将经由SSPI函数所回传的安全性Blobs记录至一个像乌龟的构造中,此时电脑可以读取Blob并将它传回SSPI(想像第一个服务器的授权者模拟一个客户端的情形,二者之间就像透过一个爬虫类通讯一样)。

很难想像到常见用户端/服务器环境中的SSPI竟无法符合您的需要,最可能的情况是您被强迫支持SSPI不支援的现行通讯协定。在这种情况下,您可能会被迫使用CryptoAPI或一些其他的函数组,以处理密码学及其他的细节。然而,假如您喜欢从基础开始设计客户端及服务器的工作,或者结合已经使用SSPI支援协定的软件,那么您应该在程序代码中使用SSPI。

表12-1中显示在编写本书时SSPI所支援的安全性通讯协定,包括哪种平台可用的通讯协定及每个通讯协定的专门特色。这个资讯可以帮助您决定最适当的通讯协定。

 表12-1 SSPI通讯协定
通讯协定 支援的作业系统 说明
Kerberos Windows 2000
  • Windows整合的验证(就是权杖撷取及客户端模拟)。
     
  • 彼此验证。
     
  • 委派。
     
  • 讯息加密及签章。
     
  • 效率及可伸缩性。
     
NT LAN Manager(NTLM) Windows 2000、
Windows NT
Windows 95/98
  • Windows整合的验证(就是权杖撷取及客户端模拟)。
     
  • 不彼此验证。
     
  • 不委派。
     
  • 讯息加密及签章。
     
  • 较低的效率及可伸缩性,与Kerberos比 较。
     
Secure Sockets Layer/Transport Layer Security(SSL/TLS) Windows 2000、
Windows NT
  • 使用凭证及公开金钥基础建设(PKI)验证。
     
  • 经由对应凭证到网域帐户的选择性模拟。
     
  • 彼此验证。
     
  • 讯息加密及签章。
     
  • 好的可伸缩性。
     
协商(Negotiate) Windows 2000、
Windows NT
Windows 95/98
  • 这个特殊通讯协定可让客户端及服务器 协商最适合使用的通讯协定。客户端应 该不要使用协商的功能,但是应该选择 它所支援之最好的通讯协定。服务器可 以使用协商通讯协定,不管客户端选择的 通讯协定是什么。
     

如同您在表12-1所看到的,在开发安全的服务器时,您可以有数个选择。对于企业软件,您可以在客户端及服务器软件间选择使用Kerberos、NTLM或协商通讯。至于Internet服务器软件,则有些原因使得必须使用SSL,这些部份将在稍后讨论。

安全连接的程序设计
 

SSPI提供了一组常见的函数,可以被用来验证客户端及服务器,并且在您软件的通讯中保证资料的私密性及完整性。SSPI被设计为Windows支援之多种SSPs所建立的一般介面,而且它做到了极大的范围。

这一节我们要讨论如何经由Kerberos及NTLM通讯协定使用SSPI初始客户端及服务器之间的通讯。SSPI以相同的程序代码做些稍微的修改,就可被用来利用Kerberos及NTLM的方式。因为种种的安全性通讯协定,使得某些SSPs会使您的SSPI程序代码大大地偏离其他通讯协定的SSPI程序代码。SSL是此类SSP的例子,因此,我们将在本章稍后以单独一节讨论SSL的内容。


说明

SSPI函数组在Windows 95/98、Windows NT及Windows 2000上皆可取得。在您的服务器及客户端软件应该都会使用到SSPI,因此在所有平台上都可得到SSPI函数是非常有帮助的。从这个平台到下一个,基本上这些函数的使用是相同的,但是也有某些细节是不同的。 本书着重在Windows 2000的软件开发,并且包含这个平台所使用的SSPI。本章讨论的观念可以在其他平台上执行时,作为您的SSPI程序代码的指导指南。有关平台之间的差异,请查阅《Platform SDK》文件。



说明

假如您要在程序代码中使用SSPI函数,应该要确定您的原始程序代码中有包含Security.h标头档。此外,您必须将Secur32.lib与您的应用程序连结。


凭证(Credentials)、内容(Contexts)
 

及二进制大型物件 (Blobs)
 

SSPI依据两种您非常熟悉的资料所建构,分别是 凭证  内容 。凭证是允许验证的资料,这个资料可以是使用者名称及密码,或使用公开金钥基础建设(PKI)的签章资讯。您将看到的任何SSPI交换,其客户端及服务器都将建立它们在交换中想要使用的凭证。凭证本身存在于SSPI的外部,而您将使用SSPI来撷取凭证的handle。

SSPI函数将使用您的客户端或服务器软件所建立的凭证来产生使用者内容。客户端及服务器软件将互相传递它们的内容,并提供验证。服务器软件也能够模拟客户端传送给它的使用者内容。内容中不包含凭证资讯,但却包含来自凭证以及在通讯工作阶段中使用的资讯。这些资讯可以包括工作阶段金钥、Kerberos许可证及其他有关安全性的资料。

您的软件将会维持由SSPI管理的凭证handles及内容的handles。同样地,您的软件将传递内容资讯到它的副本中,以使它能够在会谈的另一端建立内容资讯。服务器以SSPI所产生的安全性Blob形式传送内容到它的客户端(反之亦然)。当您的软件从SSPI接收Blob时,它应该要透过线路来传递。当您的软件从线路中接收Blob时,它会传递Blob到SSPI函数中。

使用SSPI时,您将看到会谈的验证阶段转变成连续传送及接收Blobs的过程,它使SSPI为每一端建立使用者内容。因为完成验证的责任在服务器及客户端之间完全地分摊,所以逻辑的流程可能会令人感到困惑。但是它有助于了解相关元件及函数的内容。

首先我将介绍服务器所使用的SSPI函数,然后再介绍客户端使用的函数,我们也将讨论它们互相影响的情形。最后我们将看看呼叫这些函数的细节。

服务器及SSPI
 

使用SSPI为安全连线的服务器时,最有可能这样做,达到下列的一或多个目标:

  • 初始验证/工作阶段。
     
  • 模拟。
     
  • 资料私密性或加密。
     
  • 资料完整性或讯息签章。
     

表12-2显示您的服务器所使用的函数,帮助执行的工作分类。

 表12-2 服务器的SSPI函数
工作 函数 说明
初始验证/工作阶段 AcquireCredentialsHandle 用来撷取象徵凭证的初始化handle。 一个或多个客户端环境可以与一个凭 证handle关联,同时选择您所使用的 安全性通讯协定。
AcceptSecurityContext 重覆地呼叫。Blobs从被传递到 AcceptSecurityContext的客户端函数InitializeSecurityContext传回,反之亦 然,直到环境已经完整为止。
模拟 ImpersonateSecurityContext 您的现行线程模拟的完整环境。
资料私密性/加密 EncryptMessage DecryptMessage 传递加密资料及Blobs的建立。 取得由EncryptMessage建立的 Blobs,并传回解密的资料。
资料完整性及讯息签章 MakeSignature 签章应用程序提供的资料及建立通讯 用的Blobs。
VerifySignature 取得从MakeSignature传回的Blobs及 检查包含在Blobs里的讯息签章。
清除 DeleteSecurityContext FreeCredentialsHandle 当您使用完时,用来清除 handles。

表12-2中的函数是您的服务器使用SSPI交谈的工具。大部分的时间,您的服务器将从这些必须透过线路与客户端通讯的函数中收到Blobs。

其他的时间里,您的服务器将会等待来自客户端的Blobs。图12-3的流程图从服务器的观点说明会谈的一般范例。


说明

在这个图解中,我在本章中皆会提到称为SendData及ReceiveData的函数,您应该把它看成通讯函数(例如ReadFile及WriteFile,或是WSARecv及WSASend)的替代符号(PlaceHolders)。这样做是为了强调SSPI是传输层的论点—即是独立的。



 


 图12-1 中,NTLM在客户端及服务器之间需要叁个通讯行程:验证请求、挑战转送、回传回应。当您使用NTLM验证SSPI程序代码时,它会重覆InitializeSecurityContext回圈二次,并要求您的客户端与服务器通讯叁次。这些回圈直接与 图12-1 中所见的通讯行程有关。然而,如果您使用Kerberos验证的方式,SSPI将只需要一个动作即可通过整个SSPI程序。这可能会使人感到惊讶,请再浏览图12-2的Kerberos通讯协定一次,其显示出客户端与服务器间只通讯一次(假如需要彼此验证,客户端会与服务器通讯二次)。依据SSPI顺序的程序代码流程会反映出此部份的内容。

 图12-4 客户端观点的安全会谈。请注意,在这个图表中被编号的方块和图12-3所列相符

注意在图12-4中,客户端的逻辑流程与服务器非常相似,但是客户端呼叫了一些不同的函数,而且它是启动器(客户端的第一次通讯通常是传送而非接收)。

SSPI的基本原理
 

一旦您了解客户端及服务器之间的通讯是在一个架构内部被管理后,SSPI对您来说是很容易的。编写具有足够弹性的程序代码在任一时间点循环,以建立一个可能是挑战「内容」的目标,所以在进入这个主题之前,让我先说明基本原理的部份。

如您所知道的,SSPI可让您使用不同的安全性通讯协定交谈,它们以SSP的形式封装。依据必须传输什么及何时传输的部份来说,不同的通讯协定会有不同的需要,这也是某些SSPI循环及管理Blob的原因。

我们用NTLM来举例说明。请看 


说明

SSPI只对您的软件揭露客户端及服务器之间的通讯。任何与第叁方的通讯,例如KDC或DC,会在SSPI的背景处理。


好了,我们已经谈论够多关于这部份的内容了。现在让我们看看用来实作SSPI程序代码的函数部份。

取得凭证
 

开发SSPI程序代码的第一步是向SSPI建立您自己的凭证,以及您想使用的安全性通讯协定。您可以呼叫AcquireCredentialsHandle来达成:

SECURITY_STATUS AcquireCredentialsHandle(
	SEC_CHAR*	pszPrincipal,
	SEC_CHAR*	pszPackage,
	ULONG	lCredentialUse,
	PLUID	pvLogonId,
	PVOID	pAuthData,
	PVOID	pGetKeyFn,
	PVOID	pvGetKeyArgument,
	PCredHandle	phCredential,
	PtimeStamp	ptsExpiration);

请注意,AcquireCredentialsHandle函数中有一些新的资料类型。SEC_CHAR资料类型是SSPI函数使用的字串类型,而且它会转换成一个TCHAR的类型。SECURITY_STATUS类型是所有SSPI函数传回的错误值。 表12-6 显示了一些一般定义的错误值。

因为必须传递的参数数量很多,使得AcquireCredentialsHandle看起来可能会令人怯步,但是您通常可以传递NULL给它们。pszPrincipal参数是您所撷取凭证之handle的原则名称或实体名称。您通常可以传递NULL给这个值,以指出现行线程(或程序)权杖的身分识别。

您可以传递一串字串给pszPackage参数,表明您想使用的安全性通讯协定。《Platform SDK》中有代表Kerberos、NTLM、协商及SSL的对应字串名称值。表12-4中列出了这些值。

 表12-4 可以传递给AcquireCredentialsHandle之pszPackage参数的安全性封装值
通讯协定
MICROSOFT_KERBEROS_NAME Kerberos安全性支援提供者
NTLMSP_NAME NTLM安全性支援提供者
NEGOSSP_NAME 协商安全性支援提供者
UNISP_NAME SChannel安全性支援提供者(SSL)

您应该传递表12-4中定义的其中一个值给pszPackage参数。

lCredentialsUse参数可以被设定为表12-5中的其中一个值,它指出如何使用特定的凭证handle。

 表12-5 可以传递给AcquireCredentialsHandle之lCredentialUse的参数值,指出凭证如何被使用
说明
SECPKG_CRED_INBOUND 您的软件将验证进入的使用者内容(这是服务器的一般设定)。
SECPKG_CRED_OUTBOUND 您的软件会被远端的那方验证(这是客户端的一般设定)。
SECPKG_CRED_BOTH 这个值指出您凭证handle的两个用法,是支援彼此验证服务器的一般设定。

pvLogonId参数被呼叫AcquireCredentialsHandle的文件系统服务所使用,您应该传递NULL给它。

pAuthData参数被用来支援您要用来建立凭证handle的特定协定凭证。假如您传递NULL值给它,则会传回一个凭证handle给您权杖的凭证。NTLM、Kerberos及协商安全性提供者也可让您传递一个指向以下所列之结构实体指标给pAuthData参数:

typedef struct {
	SEC_CHAR*	User;
	ULONG	UserLength;
	SEC_CHAR*	Domain;
	ULONG	DomainLength;
	SEC_CHAR*	Password;
	ULONG	PasswordLength;
	ULONG	Flags;
} SEC_WINNT_AUTH_IDENTITY;

假如您使用了 _SEC_WINNT_AUTH_IDENTITY结构并且传递它给pAuthData参数,您应该将结构设定如下:

SEC_WINNT_AUTH_IDENTITY authIdentity = {0};
authIdentity.User = TEXT("UserName");
authIdentity.UserLength = lstrlen(TEXT("UserName"));
authIdentity.Domain = TEXT("Domain");
authIdentity.DomainLength = lstrlen(TEXT("Domain"));
authIdentity.Password = TEXT("Password");
authIdentity.PasswordLength = lstrlen(TEXT("Password"));
authIdentity.Flags = SEC_WINNT_AUTH_IDENTITY_UNICODE;

当然,您应该使用实际的使用者名称及网域和密码值。假如您用ANSI代替Unicode编译,则您应该指派SEC_WINNT_AUTH_IDENTITY_ANSI值给结构中的Flags成员。

AcquireCredentialsHandle函数可让您定义回呼(Callback)函数,它用来建立与SSPI一起使用的金钥。不需要这项特色的大多数软件可以传递NULL给pGetKeyFn及pvGetKeyArgument参数。

您应该传递PcredHandle变数的位址给hCredential参数。AcquireCredentialsHandle函数会传回最近建立的凭证handle给这个变数。最后,您应该传递TimeStamp结构的位址给ptsExpiration参数。


说明

有几个SSPI函数会传回TimeStamp结构。这个结构可与使用FileTimeToSystemTime函数的标准FILETIME结构交换。 所有SSPI函数应该传回本地时间的TimeStamp(或FILETIME)资讯。有关FILETIME结构的更多资讯,请参阅《Platform SDK》文件的内容。


就像所有的SSPI函数一样,当AcquireCredentialsHandle执行成功时,它会传回SEC_E_OK。表12-6中列出了一些相关的SECURITY_STATUS值。

 表12-6 相关的SECURITY_STATUS值
状态码 说明
SEC_E_OK 全部执行成功。
SEC_E_NOT_OWNER 函数的呼叫者不是要求凭证的拥有者。
SEC_E_INVALID_HANDLE 一个无效的handle被传递到某个函数中。
SEC_E_INVALID_TOKEN 一个无效的权杖被传递到某个函数中。
SEC_E_NOT_SUPPORTED 指定的支援提供者不支援所要求的特色。
SEC_E_QOP_NOT_SUPPORTED 指定的支援提供者不支援quality-of-protection属性。
SEC_E_NO_IMPERSONATION 提供的环境没有模拟权杖。
SEC_E_TARGET_UNKNOWN 目标未知。
SEC_E_SECPKG_NOT_FOUND 指定的安全套件未知。
SEC_E_NO_IMPERSONATION 模拟不被环境允许。
SEC_E_LOGON_DENIED 原则无法登录,因为它不持有要求的凭证。
SEC_E_UNKNOWN_CREDENTIALS 提供的凭证不被承认。
SEC_E_NO_CREDENTIALS 凭证无效。
SEC_E_MESSAGE_ALTERED 验证或加密提供的讯息已经在传输中被修改。
SEC_E_OUT_OF_SEQUENCE 验证提供的讯息脱离了顺序。
SEC_E_NO_AUTHENTICATING_AUTHORITY 无法到达KDC或DC。
SEC_E_CONTEXT_EXPIRED 一个届期的环境,而且现在无效。
SEC_E_INCOMPLETE_MESSAGE 提供的讯息不完整。
SEC_I_CONTINUE_NEEDED 环境不完整,而且函数必须被再次呼叫。
SEC_I_COMPLETE_NEEDED 函数完整,但是您必须呼叫Complete AuthToken。
SEC_I_COMPLETE_AND_CONTINUE 您必须呼叫CompleteAuthToken,并且重覆执行并再次呼叫函数。
SEC_I_INCOMPLETE_CREDENTIALS 远端的那方要求更完整的凭证。若客户端的现行凭证是匿名的,可以适用这个状态码。
SEC_I_RENEGOTIATE 远端的那方要求的凭证被重新协商。
SEC_E_INSUFFICIENT_MEMORY 提供的缓冲器太小。

以下所列的程序代码片段显示了常见呼叫AcquireCredentialsHandle函数的情形:

CredHandle hCredentials; 
TimeStamp tsExpires;
ss = AcquireCredentialsHandle(NULL, MICROSOFT_KERBEROS_NAME,
	SECPKG_CRED_BOTH, NULL, NULL, NULL,
	NULL, &hCredentials, &tsExpires );
ReportSSPIError(L"AcquireCredentialsHandle", ss);
if(ss != SEC_E_OK){
	// 错误
}

假如这个呼叫执行成功,hCredentials变数将会持有一个有效的handle,可以在彼此验证的Kerberos会谈中使用。当您用完这个handle后,请把它传递到FreeCredentialsHandle函数中释放它:

SECURITY_STATUS FreeCredentialsHandle(
	PCredHandle phCredential);

现在您已经拥有凭证的handle,可以开始验证的程序。

验证-客户端的角色
 

如您所知,客户端及服务器的验证程序并不相同。因为验证通常从客户端开始,所以我们先用客户端的InitializeSecurityContext函数开始说明验证程序的内容。

SECURITY_STATUS InitializeSecurityContext(
	PcredHandle	phCredential,
	PCtxtHandle	phContext,
	SEC_CHAR	*pszTargetName,
	ULONG	lContextReq,
	ULONG	lReserved1,
	ULONG	lTargetDataRep,
	PSecBufferDesc	pInput,
	ULONG	lReserved2,
	PCtxtHandle	phNewContext,
	PSecBufferDesc	pOutput,
	PULONG	plContextAttr,
	PTimeStamp	ptsExpiration);

InitializeSecurityContext函数的第一个参数是您使用AcquireCredentialsHandle函数所收到的凭证handle。InitializeSecurityContext函数意图在回圈中多次呼叫某些参数。然而,有某些参数与您每次呼叫函数时并不相关。

我们先就您第一次呼叫这个函数的部份来讨论这些参数,然后再指出后续在呼叫InitializeSecurityContext的差别。

phContext结构在您第一次呼叫这个函数时为NULL值(在未来的呼叫中,它将会指向CtxtHandle变数的指标,此变数持有「进行中」的环境handle)。pszTargetName是您向服务器验证的使用者名称。假如服务器在主控机器上的系统帐户中执行,则pszTargetName为机器名称。

lContextReq参数是您向SSPI及服务器指出想要从安全会谈中获得什么内容的方法。表12-7中列出可以传递给lContextReq的标记。

 表12-7 您可以传递给InitializeSecurityContext之lContextReq参数的标记
标记 说明
ISC_REQ_DELEGATE 服务器被允许委派客户端的使用者内容。这个委派动作使服务器能够扮演客户端,表示还有另一个服务器代表客户端。
ISC_REQ_MUTUAL_AUTH 假如您设定了这个标记,服务器必须也能够向客户端验证它自己。NTLM协定不支援彼此验证的方式,但是Kerberos支援。
ISC_REQ_REPLAY_DETECT 这个标记指出您想要的安全性套件签章讯息,这可使恶意的第叁方无法执行Replay攻击。这个标记中包含了ISC_REQ_INTEGRITY标记。
ISC_REQ_SEQUENCE_DETECT SSPI将察觉这个工作阶段环境中,脱离顺序的讯息。也要求讯息签章及包含ISC_REQ_INTEGRITY标记。
ISC_REQ_CONFIDENTIALITY 您将使用这个环境产生加密讯息。Kerberos中,在您可以使讯息机密之前,必须彼此验证。NTLM不支援彼此验证,所以不用加入这个限制。
ISC_REQ_USE_SESSION_KEY 您想使用远端原则产生一个的新工作阶段金钥。
ISC_REQ_PROMPT_FOR_CREDS 假如客户端为互动式使用者,安全性套件将会试图提示使用者提出适当的凭证。所有安全性提供者都没有实作这个特色。
ISC_REQ_USE_SUPPLIED_CREDS 您提供特定套件的凭证作为这个函数的输入缓冲器。
ISC_REQ_ALLOCATE_MEMORY 安全性套件将分配内存给您的外部缓冲器。
ISC_REQ_USE_DCE_STYLE 您要求叁个通讯行程的验证交易。
ISC_REQ_DATAGRAM 您的通讯层使用资料封包格式通讯。
ISC_REQ_CONNECTION 您的通讯层使用连接导向通讯。
ISC_REQ_STREAM 您的通讯层使用资料流格式通讯。
ISC_REQ_EXTENDED_ERROR 假如环境执行失败,您要接收广泛的错误资讯。
ISC_REQ_INTEGRITY 要求讯息签章,但不是为了侦测Replay攻击或讯息排列的目的,而是明确地经由套件应用。

表12-7中的许多标记从未在您的SSPI程序代码中使用,您可以看到套件的弹性相当的高。本节中,我们将讨论一些标记的内容。

lTargetDataRep参数指出当通讯跨越网路时,您要使用什么位元组排列方案?您可以选择SECURITY_NATIVE_DREP及SECURITY_NETWORK_DREP。假如您可以选择的话,应该一直使用SECURITY_NETWORK_DREP,以提升作业系统的沟通能力。pInput参数指出传递给InitializeSecurityContext的输入资讯。使用这些参数时,可以传递Blobs及其他资讯给这个函数。在您初始呼叫InitializeSecurity Context的时候,通常会传递NULL给您的输入缓冲器,因为您没有Blobs可以开始。这个处理程序与pOutput参数相似,它会传回将被传送到服务器软件的Blobs。我们会马上讨论更多输入及输出缓冲器的细节。

您应该传递指向CtxtHandle变数的指标给phNewContext参数。系统经由这个参数传回环境的handle给您。这个Ctxthandle与您经由连续呼叫函数中的phContext栏位所传递到InitializeSecurityContext的相同。

plContextAttr参数传回经由安全性提供者加于您的工作阶段属性,这将是您在lContextReq参数中要求的属性组合及提供者所加入的属性。您应该一直检查传进此参数的值,以保证您的工作阶段拥有软件的重要属性。ptsExpiration参数将会传回一个时戳,指出您正在建立的环境所代表之工作阶段的到期时间。

后续对InitializeSecurityContext的呼叫
 

截至目前为止,我们已经讨论了您的客户端第一次呼叫InitializeSecurityContext函数的情形。对此函数的后续呼叫,可以让您在程序中注意到某些差别:

  • phContext参数必须事先指向持有phNewContext参数所传回的环境handle变数。
     
  • SSPI在后续呼叫InitializeSecurityContext的时候,会忽略pszTargetName参数。
     
  • 您会经由pInput参数将从服务器接收的Blobs传递到InitializeSecurityContext中。
     
  • 经由plContextAttr参数传回服务器的环境需求,以及为了错误而结合您的客户端需求应该被检查。
     

您应该以函数的传回值做基础,做后续对InitializeSecurityContext的呼叫。假如它传回SEC_I_CONTINUE_NEEDED,表示您的客户端应该重覆执行并再次呼叫InitializeSecurityContext。当函数传回SEC_E_OK时,表示您已拥有一个完整的环境。其他的传回值则指出错误的情形。

输入及输出缓冲器
 

直到我们确定了输入及输出缓冲器的主题后,才算完成对InitializeSecurityContext的讨论部份。SSPI函数中可找到输入及输出缓冲器,它会出现在作为pInput及pOutput参数时。

在需要时,输入及输出缓冲器提供一个让软件把资讯传给安全性支援提供者的方法,以足够弹性的方式符合任何支援通讯协定的需求。这就是为什么它们会被多数从系统传送或接收资料之SSPI函数使用的原因。

让我来为您说明使用这些缓冲器的方法。首先建立一个您所定义之SecBuffer变数的阵列,并且把它们指向您分配的内存缓冲器;然后把阵列的位址加入SecBufferDesc类型的实体中,它会指出您的阵列中有多少缓冲器。以下是SecBufferDesc结构的定义:

typedef struct _SecBufferDesc {
	ULONG	ulVersion; // 设定成SECBUFFER_VERSION
	ULONG	cBuffers;
	PSecBuffer	pBuffers;
} SecBufferDesc;

以下是SecBuffer结构的定义:

typedef struct _SecBuffer {
	ULONG	cbBuffer;
	ULONG	BufferType;
	PVOID	pvBuffer;
} SecBuffer;

SecBuffer的cbBuffer成员指出由pvBuffer成员指向的内存区块大小。SecBufferDesc的ulVersion成员应该一直被设定成SECBUFFER_VERSION。


说明

对于输出缓冲器,您可以将cbBuffer设定为0,将pvBuffer设定为NULL,系统则会分配缓冲器给您,作为传回资料用。当您用完这个传回的缓冲器时,应把它们传递到FreeContextBuffer中并释放它们。您可以使用传递ISC_REQ_ALLOCATE_MEMORY环境需求给InitializeSecurityContext函数的方式要求系统分配缓冲器给您。


图12-5的说明显示了SecBuffer与SecBufferDesc之间的关系,以及您的实际内存区块与InitializeSecurityContext的内容。


 

 图12-5 SSPI输入及输出缓冲器

InitializeSecurityContext及缓冲器
 

在您学习相关的细节前,试着去了解SSPI处理缓冲器的方式可能会令人困惑,然后事情会变得相当清楚。如同之前所提的,InitializeSecurityContext使用缓冲器输入及输出所传递到服务器而准备的安全性Blobs。在第一次呼叫InitializeSecurityContext的时候,可以传递NULL给pInput参数。然而,当您从服务器接收资料时,应建构缓冲器并把它们传递给函数。

对于输入及输出的部份,每个对InitializeSecurityContext的呼叫都会取得一个SECBUFFER_TOKEN类型的SecBuffer阵列。这个缓冲器类型向系统指出这个缓冲器建立了一个环境的输入Blob或输出Blob。

以下所列的程序片段显示您如何建构与InitializeSecurityContext一起使用的缓冲器:

// 建立「输出」缓冲器
SecBuffer secBufferOut[1];
secBufferOut[0].BufferType = SECBUFFER_TOKEN;
secBufferOut[0].cbBuffer = cbBlockToSend;
secBufferOut[0].pvBuffer = pbBlockToSend;

// 建立「输出」缓冲器的描述项
SecBufferDesc secBufDescriptorOut;
secBufDescriptorOut.cBuffers = 1;
secBufDescriptorOut.pBuffers = secBufferOut;
secBufDescriptorOut.ulVersion = SECBUFFER_VERSION;

// 建立「输入」缓冲器
SecBuffer secBufferIn[1];
secBufferIn[0].BufferType = SECBUFFER_TOKEN;
secBufferIn[0].cbBuffer = cbBlockReceived;
secBufferIn[0].pvBuffer = pbBlockReceived;

// 建立「输入」缓冲器的描述项
SecBufferDesc secBufDescriptorIn;
secBufDescriptorIn.cBuffers = 1;
secBufDescriptorIn.pBuffers = secBufferIn;
secBufDescriptorIn.ulVersion = SECBUFFER_VERSION;

pbBlockReceived及pbBlockToSend缓冲器在呼叫InitializeSecurityContext之前即被分配。您可以选择要函数分配一个区块给您的输出缓冲器。

在呼叫InitializeSecurityContext后,输出缓冲器的cbBuffer成员会包含被传送到服务器的Blob大小。假如不为0的大小,则您应该从pvBuffer成员所指向的缓冲器中传递与这个值指示之一样多的位元组给服务器。这是验证的「交握(Handshake)」。

SSPI定义了一些不同的缓冲器类型。当它们与我们的讨论有关时,我将会加以说明。有关现在所定义的缓冲器类型清单,请参阅《Platform SDK》文件。

InitializeSecurityContext-把它们全部放在一起
 

当您在处理SSPI时,必须记住的细节数量之多毫无疑问会使人感到怯步,不过,先看它如何在简单的函数中产生作用将会使处理程序变得较清楚。

以下是一个范例函数,该程序使用我们所讨论过的技巧建立一个完整的环境:

BOOL ClientHandshakeAuth(CredHandle* phCredentials, 
	PULONG plAttributes, CtxtHandle* phContext, PTSTR pszServer){
	BOOL fSuccess = FALSE;
	__try{
		SECURITY_STATUS ss;

		// 宣告输入及输出的缓冲器
		SecBuffer secBufferOut[1];
		SecBufferDesc secBufDescriptorOut;
		SecBuffer secBufferIn[1];
		SecBufferDesc secBufDescriptorIn;

		// 设定一些「回圈状态」资讯
		BOOL fFirstPass = TRUE;
		ss = SEC_I_CONTINUE_NEEDED;
		while (ss == SEC_I_CONTINUE_NEEDED ){

			// 在Blob指标里
			PBYTE pbData = NULL;
			if(fFirstPass){ // 第一次传递,没有里面的缓冲器
				secBufDescriptorIn.cBuffers = 0;
				secBufDescriptorIn.pBuffers = NULL;
				secBufDescriptorIn.ulVersion = SECBUFFER_VERSION;
			}else{ // 后续的传递
				// 取得Blob的大小
				ULONG lSize;
				ULONG lTempSize = sizeof(lSize);
				ReceiveData(&lSize, &lTempSize);
				// 取得Blob
				pbData = (PBYTE)alloca(lSize);
				ReceiveData(pbData, &lSize);

				// 把「输入缓冲器」指到Blob
				secBufferIn[0].BufferType = SECBUFFER_TOKEN;
				secBufferIn[0].cbBuffer = lSize;
				secBufferIn[0].pvBuffer = pbData;
				// 把「输入」BufDesc指到里面的缓冲器
				secBufDescriptorIn.cBuffers = 1;
				secBufDescriptorIn.pBuffers = secBufferIn;
				secBufDescriptorIn.ulVersion = SECBUFFER_VERSION;
			}

			// 设定输出缓冲器(SSPI将分配缓冲器给我们)
			secBufferOut[0].BufferType = SECBUFFER_TOKEN;
			secBufferOut[0].cbBuffer = 0;
			secBufferOut[0].pvBuffer = NULL;

			// 把「输出」Bufdesc指到外面的缓冲器
			secBufDescriptorOut.cBuffers = 1;
			secBufDescriptorOut.pBuffers = secBufferOut;
			secBufDescriptorOut.ulVersion = SECBUFFER_VERSION;

			ss=
				InitializeSecurityContext(
					phCredentials,
					fFirstPass?NULL:phContext,
					pszServer,
					*plAttributes | ISC_REQ_ALLOCATE_MEMORY,
					0, SECURITY_NETWORK_DREP,
					&secBufDescriptorIn, 0,
					phContext,
					&secBufDescriptorOut,
					plAttributes, NULL);

			// 通过回圈表示不再是第一次传递
			fFirstPass = FALSE;

			// 是Blob输出吗?假如是则传递它。
			if (secBufferOut[0].cbBuffer!=0){
				// 服务器通讯!!!
				// 传递Blob的大小
				SendData(&secBufferOut[0].cbBuffer, sizeof(ULONG));
				// 传送Blob本身
				SendData(secBufferOut[0].pvBuffer,
					secBufferOut[0].cbBuffer);

				// 释放缓冲器
				FreeContextBuffer(secBufferOut[0].pvBuffer);
			}
		}// 重覆执行if ss == SEC_I_CONTINUE_NEEDED;
		// 最后的结果
		if (ss != SEC_E_OK){
			__leave;
		}

		fSuccess = TRUE;
	}__finally{
		// 假如我们执行失败了,清除环境handle
		if (!fSuccess){
			ZeroMemory(phContext, sizeof(*phContext));
		}
	}
	return (fSuccess);
}

ClientHandshakeAuth函数取得从AcquireCredentialsHandle传回的凭证handle、一些标记及服务器名称,以及假如建立环境成功所传回的一个完整环境。

请注意,ClientHandshakeAuth函数在程序代码中的两个地方与服务器通讯。我用来通讯的函数名称为SendData及ReceiveData的虚构函数。它们取得一个缓冲器及其大小,并且为您的软件所选择的任何通讯机制的替代符号(Placeholders)。

也请注意到当传输一个Blob时,会在传送Blob前先传送Blob的大小,而当收到一个Blob时,会在接收它之前先读取Blob的大小。


说明

因为SSPI是通讯传输独立的,建立一些用来传递Blobs到另一端的原则通讯协定类型对您来说是必要的。


特别注意这个函数中的缓冲器管理,以及当跨越线路传递需要再次循环的Blob及函数时,InitializeSecurityContext会如何传递到我们的软件。这是客户端与SSPI验证交握的职责核心。

以下的程序代码片段可以用来取得凭证handle及呼叫ClientHandshakeAuth,表示开始使用Kerberos通讯协定验证:

CredHandle hCredentials; 
TimeStamp tsExpires;
SECURITY_STATUS ss = AcquireCredentialsHandle(NULL,
	MICROSOFT_KERBEROS_NAME, SECPKG_CRED_BOTH, NULL, NULL,
	NULL, NULL, &hCredentials, &tsExpires );
if(ss != SEC_E_OK){
	// 错误
}

ULONG lAttributes =
	ISC_REQ_STREAM|ISC_REQ_CONFIDENTIALITY|ISC_REQ_MUTUAL_AUTH;
CtxtHandle hContext = {0};
if(!ClientHandshakeAuth(&hCredentials, &lAttributes,
	&hContext, TEXT("jclark-piii600"))){
	// 错误
}
	// 假如成功,我们已经在这里验证
DeleteSecurityContext(&hContext);
FreeCredentialsHandle(&hCredentials);

在这个程序代码里,AcquireCredentialsHandle传回了凭证handle,表示呼叫函数的身分识别。然后我们呼叫了我们的范例函数ClientHandshakeAuth,指出我们要使用彼此验证及加密功能,以及我们将使用资料流技术通讯。我们现在要谈论SSPI中有关服务器端的验证部份。在您离开这个主题前,您会发现花些时间复习 

验证-服务器的角色
 

使用SSPI了解客户端在验证交握中的角色,对于了解服务器的角色是有帮助的。事实上,一旦了解其中一端后,另一端就变得非常容易。管理Blobs的服务器函数是AcceptSecurityContext:

SECURITY_STATUS AcceptSecurityContext(
	PcredHandle	phCredential,
	PCtxtHandle	phContext,
	PSecBufferDesc	pInput,
	ULONG	lContextReq,
	ULONG	lTargetDataRep,
	PCtxtHandle	phNewContext,
	PSecBufferDesc	pOutput,
	PULONG	pfContextAttr,
	PTimeStamp	ptsExpiration);

请注意,AcceptSecurityContext拥有与InitializeSecurityContext相同的参数,它没有两个预留的参数及指出服务器名称的参数。当然,它不需要服务器名称,因为它呼叫AcceptSecurityContext的服务器。

就像它的客户端角色一样,AcceptSecurityContext的角色是接收及产生Blobs。AcceptSecurityContext及InitializeSecurityContext都必须被允许重覆执行,直到它们传回SEC_E_OK为止。在使用AcceptSecurityContext时,有两个值得注意的地方:

  • 在您第一次呼叫AcceptSecurityContext时,您已经从您的客户端收到了第一个Blob,所以总是有一个与这个函数一起使用的输入缓冲器(这和InitializeSecurityContext不同,它的初始传递没有输入缓冲器)。
     
  • 除了AcceptSecurityContext的lContextReq参数值有不同的前置字元外,AcceptSecurityContext使用了与InitializeSecurityContext相同的环境需求(列于表12-7)。取代以「ISC_REQ_」开始的「InitializeSecurityContext需求」,AcceptSecurityContext的需求以「ASC_REQ_」开始。例如,ISC_REQ_CONFIDENTIALITY 等于 ASC_REQ_CONFIDENTIALITY。
     

除了这两个差别之外,在您使用AcceptSecurityContext时,大部分皆与使用InitializeSecurityContext的方法相同。然而,AcceptSecurityContext产生的环境更有能力,因为服务器可以用它来模拟或用其他方式取得权杖的handle,我们稍后将讨论它。让我们看一个范例函数,它在服务器程序代码中显示AcceptSecurityContext的用途:

BOOL ServerHandshakeAuth(CredHandle* phCredentials, 
	PULONG plAttributes, CtxtHandle *phContext){
	BOOL fSuccess = FALSE;
	__try{
		SECURITY_STATUS ss;

		// 宣告输入及输出缓冲器
		SecBuffer secBufferIn[1];
		SecBufferDesc secBufDescriptorIn;

		SecBuffer secBufferOut[1];
		SecBufferDesc secBufDescriptorOut;

		// 设定一些「回圈状态」资讯
		BOOL fFirstPass = TRUE;
		ss = SEC_I_CONTINUE_NEEDED;
		while (ss == SEC_I_CONTINUE_NEEDED){

			// 客户端通讯!!!
			// 取得Blob的大小。
			ULONG lSize;
			ULONG lTempSize = sizeof(lSize);
			ReceiveData(&lSize, &lTempSize);
			// 取得Blob
			PBYTE pbTokenBuf = (PBYTE)alloca(lSize);
			ReceiveData(pbTokenBuf, &lSize);

			// 把「输入缓冲器」指到Blob
			secBufferIn[0].BufferType = SECBUFFER_TOKEN;
			secBufferIn[0].cbBuffer = lSize;
			secBufferIn[0].pvBuffer = pbTokenBuf;
			// 把「输入」BufDesc指到里面的缓冲器
			secBufDescriptorIn.ulVersion = SECBUFFER_VERSION;
			secBufDescriptorIn.cBuffers = 1;
			secBufDescriptorIn.pBuffers = secBufferIn;

			// 设定输出缓冲器
			//(SSPI将分配缓冲器给我们)
			secBufferOut[0].BufferType = SECBUFFER_TOKEN;
			secBufferOut[0].cbBuffer = 0;
			secBufferOut[0].pvBuffer = NULL;
			// 把「输出」Bufdesc指到外面的缓冲器
			secBufDescriptorOut.ulVersion = SECBUFFER_VERSION;
			secBufDescriptorOut.cBuffers = 1;
			secBufDescriptorOut.pBuffers = secBufferOut;

			// 这是我们的Blob管理函数
			ss =
				AcceptSecurityContext(
					phCredentials,
					fFirstPass?NULL:phContext,
					&secBufDescriptorIn,
					*plAttributes | ASC_REQ_ALLOCATE_MEMORY,
					SECURITY_NETWORK_DREP,
					phContext,
					&secBufDescriptorOut,
					plAttributes, NULL);

			// 通过回圈表示不再是第一次传递
			fFirstPass = FALSE;

			// 输出Blob吗?假如是,传递它。
			if (secBufferOut[0].cbBuffer != 0){

				// 客户端通讯!!!
				// 传递Blob的大小。
				SendData(&secBufferOut[0].cbBuffer, sizeof(ULONG));
				// 传送Blob本身
				SendData(secBufferOut[0].pvBuffer,
				secBufferOut[0].cbBuffer);
				// 释放缓冲器
				FreeContextBuffer(secBufferOut[0].pvBuffer);
			}
		}// 重覆执行if ss == SEC_I_CONTINUE_NEEDED;
		// 最后的结果
		if(ss != SEC_E_OK){
			__leave;
		}

		fSuccess = TRUE;
	}__finally{
		// 假如我们执行失败了,清除环境handle
		if (!fSuccess){
			ZeroMemory(phContext, sizeof(*phContext));
		}
	}
	return (fSuccess);
}

请注意,ServerHandshakeAuth使用了名称为SendData及ReceiveData的虚拟函数,它们意图代表任何的通讯机制。同样地,ServerHandshakeAuth会传送及接收Blobs的大小以使另一端知道要接收多少资料。

再者,特别注意缓冲器的管理程序代码。请注意,收到的Blobs会经由里面的缓冲器被传递到函数里。同时会检查外面缓冲器是否存在;假设它存在,则跨越线路传送它。同样地,AcceptSecurityContext的用途可让函数分配内存缓冲器给输出Blobs,最后使用和InitializeSecurityContext相同的方法,用FreeContextBuffer释放这些缓冲器。

以下的程序代码片段可被用来设定呼叫ServerHandshakeAuth范例函数里使用的凭证:

CredHandle hCredentials; 
TimeStamp tsExpires;
SECURITY_STATUS ss = AcquireCredentialsHandle(NULL,
	MICROSOFT_KERBEROS_NAME, SECPKG_CRED_BOTH, NULL, NULL,
	NULL, NULL, &hCredentials, &tsExpires );
if(ss != SEC_E_OK){
	// 错误
}

ULONG lAttributes = ASC_REQ_STREAM;
CtxtHandle hContext = {0};
if(!ServerHandshakeAuth(&hCredentials,
	&lAttributes, &hContext)){
	// 错误
}

ss = ImpersonateSecurityContext(&hContext);
if(ss != SEC_E_OK){
	__leave;
}

DeleteSecurityContext(&hContext);
FreeCredentialsHandle(&hCredentials);

模拟(Impersonation)及权杖(Token)的取得
 

从服务器的观点来说,模拟是验证的重要部分。幸运的是,当您拥有完整的环境handle时,模拟是容易的。使用以下列出示的ImpersonateSecurityContext及RevertSecurityContext函数可以达成:

SECURITY_STATUS ImpersonateSecurityContext(
	PCtxtHandle phContext);
SECURITY_STATUS RevertSecurityContext(
	PCtxtHandle phContext);

也可以使用QuerySecurityContextToken函数从安全性环境中要求权杖:

SECURITY_STATUS QuerySecurityContextToken(
	PCtxtHandle	phContext,
	HANDLE	*phToken);

使用QuerySecurityContextToken函数,可以让您取得客户端的权杖,并将它与关联的资讯一起储存,而不用做第一次模拟客户端的动作。

模拟对服务器来说是管理安全性的重要方法,管理安全性在第十一章有详细的讨论。SSPI可让您在 任何 通讯机制上模拟客户端。您不再被限定于管道或RPC的方法。假如它为您软件的需求服务时,可以使用通讯端、IPX/SPX或序列连接的方式模拟客户端。

您可以在本章稍后讨论的SSPIChat范例应用程序中找到完整的客户端模拟及服务器SSPI程序。但是现在我们要谈论更多关于讯息签章及加密的部份。

讯息签章及加密
 

一旦您在客户端及服务器之间协商验证时,客户端及服务器可以如同客户端/服务器一般在预期通讯,并开始有系统的交换讯息。要完全利用这个交换,应该使用签章或加密后传递的讯息。以下是应遵循的方针:

  1. 当不需要私密性但要确信讯息具有完整性时(经常会需要),需要对讯息做签章。
  2. 当需要私密性时,则要对讯息做加密(有加密就不需要签章)。

签章及加密以相同的方法使用与验证类似的函数,但是这个机制对客户端及服务器来说是相同的。建立签章讯息的成对函数是MakeSignature及VerifySignature。MakeSignature函数的定义如下:

SECURITY_STATUS MakeSignature(
	PCtxtHandle	phContext,
	ULONG	lQOP,
	PSecBufferDesc	pMessage,
	ULONG	MessageSeqNo);

传递资料缓冲器及完整的环境给MakeSignature,函数会传回您跨越线路所传递(与资料缓冲器一起)的签章。通讯的接收端会取得这个资讯并使用这个签章来验证资料缓冲器。再次提醒,您应该把MakeSignature传回的资料视为打算只用来做通讯用的不透明Blob。

LQOP参数是特殊的安全性通讯协定,而且可让您指定使用签章演算法的细节。通常会传递0给这个参数。假如您要记录讯息序号时,应该传递讯息的序号给MessageSeqNo参数。如果传递0,表示顺序与您无关。

如我们处理过的其他SSPI函数一般,资料会经由资料缓冲器传递到MakeSignature及接收。至于MakeSignature的部份,可以使用单一缓冲器描述项传递二个缓冲器,所以您必须建立二个SecBuffer变数阵列。设定其中一个为SECBUFFER_TOKEN类型,它将接收讯息的签章。第二个缓冲器为SECBUFFER_DATA类型,指出资料本身。这个函数会对一个资料缓冲器做签章,并使用虚拟通讯函数SendData传送它:

BOOL SendSignedMessage(CtxtHandle* phContext, PVOID pvData, ULONG lSize) 
{
	BOOL fSuccess = FALSE;
	__try{
		SECURITY_STATUS ss;

		// 找出某些重要的最大缓冲器大小资讯
		SecPkgContext_Sizes sizes;
		ss = QueryContextAttributes(phContext, SECPKG_ATTR_SIZES, &sizes );
		if(ss != SEC_E_OK){
			__leave;
		}

		PVOID pvSignature = alloca(sizes.cbMaxSignature);
		SecBuffer secBuffer[2];
		// 设定缓冲器接收签章
		secBuffer[0].BufferType = SECBUFFER_TOKEN;
		secBuffer[0].cbBuffer = sizes.cbMaxSignature;
		secBuffer[0].pvBuffer = pvSignature;
		// 设定缓冲器指向讯息资料
		secBuffer[1].BufferType = SECBUFFER_DATA;
		secBuffer[1].cbBuffer = lSize;
		secBuffer[1].pvBuffer = pvData;
		// 设定缓冲器描述项
		SecBufferDesc secBufferDesc;
		secBufferDesc.cBuffers = 2;
		secBufferDesc.pBuffers = secBuffer;
		secBufferDesc.ulVersion = SECBUFFER_VERSION;
		// 制作签章
		ss = MakeSignature(phContext, 0, &secBufferDesc, 0 );
		if(ss != SEC_E_OK){
			__leave;
		}

		// 传送签章
		SendData(&secBuffer[0].cbBuffer, sizeof(ULONG));
		SendData(secBuffer[0].pvBuffer, secBuffer[0].cbBuffer);

		// 传送讯息
		SendData(&secBuffer[1].cbBuffer, sizeof(ULONG));
		SendData(secBuffer[1].pvBuffer, secBuffer[1].cbBuffer);

		fSuccess = TRUE;
	}__finally{
	}
	return (fSuccess);
}

说明

和InitializeSecurityContext及AcceptSecurityContext 不同,MakeSignature没有个别的输入及输出缓冲器,它也不会为输入分配缓冲器。这意味着您必须提供够大的缓冲器给产生的签章。您可以使用具有SECPKG_ATTR_SIZES属性的QueryContextAttributes函数找出最大的签章大小。QueryContextAttributes函数的定义如下:

SECURITY_STATUS QueryContextAttributes(
	PCtxtHandle	phContext,
	ULONG	lAttribute,
	PVOID	pBuffer);

在这个情况下,您需要传递一个函数中的SecPkgContext_Sizes结构实体。以下是结构的定义:

typedef struct _SecPkgContext_Sizes {
	ULONG cbMaxToken;
	ULONG cbMaxSignature;
	ULONG cbBlockSize;
	ULONG cbSecurityTrailer; } SecPkgContext_Sizes;

SecPkgContext_Sizes结构适当地包含了某些有关最大大小的资讯,包括与MakeSignature一起使用的cbMaxSignature,这些皆显示在前面的程序代码范例中。


交易的另一端必需读取签章及讯息资料,并将它们传递到VerifySignature中:

SECURITY_STATUS VerifySignature(
	PCtxtHandle	phContext,
	PSecBufferDesc	pMessage,
	ULONG	MessageSeqNo,
	PULONG	plQOP);

VerifySignature函数与MakeSignature相似,因为它会取得相同的缓冲器及类型。然而,VerifySignature只确保讯息为被修改以及没有修改缓冲器。以下是一个范例应用程序,它会接收签章及讯息,并呼叫VerifySignature函数:

PVOID GetSignedMessage(CtxtHandle* phContext, PULONG plSize)
{
	PVOID pvMessage = NULL;
	__try{
		SECURITY_STATUS ss;

		ULONG lSigLen;
		PVOID pvDataSig;
		// 取得签章长度
		ULONG lTempSize = sizeof(lSigLen);
		ReceiveData(&lSigLen, &lTempSize);
		pvDataSig = alloca(lSigLen);
		// 取得签章
		ReceiveData(pvDataSig, &lSigLen);

		ULONG lMsgLen;
		PVOID pvDataMsg;
		// 取得讯息长度
		lTempSize = sizeof(lMsgLen);
		ReceiveData(&lMsgLen, &lTempSize);
		pvDataMsg = alloca(lMsgLen);
		// 取得讯息
		ReceiveData(pvDataMsg, &lMsgLen);

		SecBuffer secBuffer[2];
		// 设定签章缓冲器
		secBuffer[0].BufferType = SECBUFFER_TOKEN;
		secBuffer[0].cbBuffer = lSigLen;
		secBuffer[0].pvBuffer = pvDataSig;
		// 设定讯息缓冲器
		secBuffer[1].BufferType = SECBUFFER_DATA;
		secBuffer[1].cbBuffer = lMsgLen;
		secBuffer[1].pvBuffer = pvDataMsg;
		// 设定缓冲器描述项
		SecBufferDesc secBufferDesc;
		secBufferDesc.cBuffers = 2;
		secBufferDesc.pBuffers = secBuffer;
		secBufferDesc.ulVersion = SECBUFFER_VERSION;
		ULONG lQual=0;
		// 验证签章
		ss = VerifySignature(phContext, &secBufferDesc, 0, &lQual);
		if (ss != SEC_E_OK){
			__leave;
		}
		// 传回必须被释放的缓冲器,包含讯息
		pvMessage = LocalAlloc(LPTR, secBuffer[1].cbBuffer);
		if (pvMessage != NULL){
			CopyMemory(pvMessage, secBuffer[1].pvBuffer,
				secBuffer[1].cbBuffer);
		}
	}__finally{};
	return pvMessage;
}

假如讯息在传输过程中被修改,则VerifySignature将会传回SEC_E_MESSAGE_ ALTERED或SEC_E_OUT_OF_SEQUENCE值。

加密讯息
 

加解密讯息的过程和签章及验证讯息的情形非常相似,讯息会在适当的地方被加解密。这意味着您所传递的资料缓冲器将被这些函数修改。因为某些加密演算法要求加密的资料须有多种特定区块的大小(例如8位元或16位元),所以检查区块大小是必要的,包括「加密溢位」类型的第叁个缓冲器,它也必须跨越线路而被传送。我们要使用的函数为EncryptMessage及DecryptMessage。EncryptMessage函数定义如下:

SECURITY_STATUS EncryptMessage(
	PCtxtHandle	phContext,
	ULONG	lQOP,
	PSecBufferDesc	pMessage,
	ULONG	MessageSeqNo);

以下为DecryptMessage函数的定义:

SECURITY_STATUS DecryptMessage(
	PCtxtHandle	phContext,
	PSecBufferDesc	pMessage,
	ULONG	MessageSeqNo,
	PULONG	plQOP
);

以下为一个范例函数,它会对讯息加密并经由SendData的使用,跨越线路传送讯息:

BOOL SendEncryptedMessage(CtxtHandle* phContext, 
	PVOID pvData, ULONG lSize){
	BOOL fSuccess = FALSE;
	__try{
		SECURITY_STATUS ss;

		// 取得某些重要大小资讯
		SecPkgContext_Sizes sizes;
		ss = QueryContextAttributes(phContext, SECPKG_ATTR_SIZES, &sizes);
		if(ss != SEC_E_OK){
			__leave;
		}

		// 分配我们的缓冲器
		PVOID pvPadding = alloca(sizes.cbBlockSize);
		PVOID pvSignature = alloca(sizes.cbSecurityTrailer);

		// 最好复制讯息缓冲器,因为它会在适当的地方被加密
		PVOID pvMessage = alloca(lSize);
		CopyMemory(pvMessage, pvData, lSize);

		SecBuffer secBuffer[3] == {0};
		// 设定签章缓冲器
		secBuffer[0].BufferType = SECBUFFER_TOKEN;
		secBuffer[0].cbBuffer = sizes.cbSecurityTrailer;
		secBuffer[0].pvBuffer = pvSignature;
		// 设定讯息缓冲器
		secBuffer[1].BufferType = SECBUFFER_DATA;
		secBuffer[1].cbBuffer = lSize;
		secBuffer[1].pvBuffer = pvMessage;
		// 设定填充缓冲器
		secBuffer[2].BufferType = SECBUFFER_PADDING;
		secBuffer[2].cbBuffer = sizes.cbBlockSize;
		secBuffer[2].pvBuffer = pvPadding;
		// 设定缓冲器描述项
		SecBufferDesc secBufferDesc;
		secBufferDesc.cBuffers = 3;
		secBufferDesc.pBuffers = secBuffer;
		secBufferDesc.ulVersion = SECBUFFER_VERSION;
		// 加密讯息
		ss = EncryptMessage(phContext, 0, &secBufferDesc, 0 );
		if(ss != SEC_E_OK){
			__leave;
		}

		// 传送权杖
		SendData(&secBuffer[0].cbBuffer, sizeof(ULONG));
		SendData(secBuffer[0].pvBuffer, secBuffer[0].cbBuffer);

		// 传送讯息
		SendData(&secBuffer[1].cbBuffer, sizeof(ULONG));
		SendData(secBuffer[1].pvBuffer, secBuffer[1].cbBuffer);

		// 传送填充
		SendData(&secBuffer[2].cbBuffer, sizeof(ULONG));
		SendData(secBuffer[2].pvBuffer, secBuffer[2].cbBuffer);

		fSuccess = TRUE;
	}__finally{}

	return fSuccess;
}

请注意,这个程序代码与您已经见过的讯息签章程序代码非常相似。列于下方的函数为GetEncryptedMessage,它是一个与前述函数相配范例函数,用来将讯息解密并传回缓冲器:

PVOID GetEncryptedMessage(CtxtHandle* phContext, PULONG plSize){ 
	PVOID pvMessage = NULL;
	__try{
		SECURITY_STATUS ss;

		ULONG lSigLen;
		PVOID pvDataSig;
		// 取得签章长度
		ULONG lTempSize = sizeof(lSigLen);
		ReceiveData(&lSigLen, &lTempSize);
		pvDataSig = alloca(lSigLen);
		// 取得签章
		ReceiveData(pvDataSig, &lSigLen);

		ULONG lMsgLen;
		PVOID pvDataMsg;
		// 取得讯息长度
		lTempSize = sizeof(lMsgLen);
		ReceiveData(&lMsgLen, &lTempSize);
		pvDataMsg = alloca(lMsgLen);
		// 取得讯息
		ReceiveData(pvDataMsg, &lMsgLen);

		ULONG lPadLen;
		PVOID pvDataPad;
		// 取得填充长度
		lTempSize = sizeof(lPadLen);
		ReceiveData(&lPadLen, &lTempSize);
		pvDataPad = alloca(lPadLen);
		// 取得填充内容
		ReceiveData(pvDataPad, &lPadLen);

		SecBuffer secBuffer[3] = {0};
		// 设定签章缓冲器
		secBuffer[0].BufferType = SECBUFFER_TOKEN;
		secBuffer[0].cbBuffer = lSigLen;
		secBuffer[0].pvBuffer = pvDataSig;
		// 设定讯息缓冲器
		secBuffer[1].BufferType = SECBUFFER_DATA;
		secBuffer[1].cbBuffer = lMsgLen;
		secBuffer[1].pvBuffer = pvDataMsg;

		// 设定填充缓冲器
		secBuffer[2].BufferType = SECBUFFER_PADDING;
		secBuffer[2].cbBuffer = lPadLen;
		secBuffer[2].pvBuffer = pvDataPad;
		// 设定缓冲器描述项
		SecBufferDesc secBufferDesc;
		secBufferDesc.cBuffers = 3;
		secBufferDesc.pBuffers = secBuffer;
		secBufferDesc.ulVersion = SECBUFFER_VERSION;
		ULONG lQual=0;
		ss = DecryptMessage( phContext, &secBufferDesc, 0, &lQual );
		if (ss != SEC_E_OK){
			__leave;
		}

		// 传回一个必须被释放的缓冲器,包含讯息
		pvMessage = LocalAlloc(LPTR, secBuffer[1].cbBuffer);
		if (pvMessage != NULL){
			CopyMemory(pvMessage, secBuffer[1].pvBuffer,
				secBuffer[1].cbBuffer);
		}
	}__finally{}
	return (pvMessage);
}

现在,您已经熟悉了对讯息加密及签章的部份,并且也拥有实作完整工作阶段和SSPI的工具。所以您应该知道如何去协商验证,利用服务器模拟客户端以及如何以安全的方法传递及接收资料的部份。

SSPIChat范例应用程序
 

SSPIChat范例应用程序(「12 SSPIChat.exe」)示范了截至目前为止,所有我们讨论过的SSPI相关技巧,包括客户端及服务器验证协商、模拟、讯息签章及加密的部份。这个范例应用程序的原始程序代码及资源档存放在随书光碟上的12-SSPIChat目录中。图12-6显示了SSPIChat范例应用程序的使用者介面。


 

 图12-6 SSPIChat范例应用程序的使用者介面

使用这个范例时,您应该连线到网路的同一台机器或不同机器上执行一次以上。这个范例应用程序使用TCP/IP通讯的方式。您可以在初始会谈之前选择要使用的安全性提供者,也可以选择是否要执行彼此验证、加密或委派的动作。

委派的特色使服务器建立了第二个假定为客户端身分识别的交谈视窗。然后新的视窗可以被用来对将它看做原始客户端的第叁个服务器扮演客户端(假如服务器机器未被委派信任,这个特色将会失败)。

由于通讯层独立的精神,使这个范例应用程序中的通讯功能被抽象化为CTransport类别,它包括了SendData及ReceiveData函数。这两个函数非常像我在本章的程序代码片段中使用的虚拟函数。

在此强烈地建议您在试图使用SSL(在本章稍后讨论)编写SSPI程序代码前,先熟悉这个应用程序的范例程序代码。SSPI程序设计模组是很复杂的,而它也带来了少数的说明。所以适应全部的方法将会使SSL令人感到更愉快。

CryptoAPI
 

CryptoAPI或者CAPI,产生用来加解密资料的金钥和输出金钥并安全地分享它们,它提供了一组完整的函数。CryptoAPI也包括完全支援凭证及凭证管理的部份。CryptoAPI提供的密码学及凭证函数被定义在WinCrypt.h中;您的程序代码应该包含这个标头文件。您也应该确实连结Crypt32.lib程序库文件。

CryptoAPI提供如此丰富的特色,使您可以用它的函数来为不安全网路环境上的通讯实作您自己的安全通讯协定。然而,SSPI已经为我们免费做了这些工作。事实上,SSPI因为实作密码需求而使用CryptoAPI。假如您对建立自己的安全通讯协定有兴趣的话,即可以使用CryptoAPI。然而,对于大部分的专案来说,您应该使用SSPI所支援的通讯协定。

在两个情况下,使用CryptoAPI是有效的,因为SSPI不能为您做这个工作。说明如下:

  •  非工作阶段相关的密码学 包括文件密码学或任何其他不会在工作阶段导向环境中被传输的加密持续资料。
     
  •  凭证管理 当我们使用SSPI讨论SSL时,您将看到通讯协定之关键性凭证的内容。凭证管理由CryptoAPI套件执行。
     

持续资料的加密并非常见的需求,而且因为Windows 2000的加密文件系统(Encrypted File System,EFS)已经完全地支援它,所以我们不在此处说明这个特殊的CryptoAPI用法。假如您对使用这个CryptoAPI加密的类型有兴趣的话,可以在《Platform SDK》文件中找到完整叙述。

然而,凭证管理是SSL安全性通讯协定不可或缺的部分。事实上,在我们开始讨论SSL之前,必须花些时间讨论使用CryptoAPI之凭证管理的部份。

凭证存放档
 

Windows在 存放档(stores) 中管理凭证。CryptoAPI可让您管理许多不同种类的存放档、单一使用者的个人存放档、机器的存放档,以及只存在于内存中的暂时存放档。

当您在建立存放档时,可以指定为它预留的媒介。或是它根本不必预留。存放档中包含凭证,如您所知的,凭证是为了验证、签章及加密时而使用的。然而在处理SSL时,除了机器的存放档(用它来储存服务器凭证)或者是个人存放档(被用来储存客户端凭证)以外,您不太可能会需要处理任何的存放档。现在我要叙述几个方案。

  •  方案一 您设计了一个使用SSL通讯的服务,它使用本机帐户在机器上执行。此时客户端将验证服务器,但是服务器并不验证客户端。您可能会为这个服务建立一个凭证(及相配的私密金钥),并把它们储存在您主机系统中的机器存放档里。服务器经由它常见的名称得知它的凭证是哪个。当服务连结到客户端时,会使用CryptoAPI查询它的凭证,然后再使用这个凭证初始使用SSPI及SSL的通讯工作阶段。在这个情况下,服务器知道要在机器存放档里查询凭证。
     

经过验证之后,客户端即拥有线上传输的凭证副本。尽管客户端不传回凭证,它仍然必须将凭证中的资讯解开,以找出它是否有连结到寻找的服务器。使用Windows 2000时,SSL会对照客户端信任的CA而自动地验证凭证链,也会在服务器名称(客户端要求的)及服务器凭证上的常见名称间作对照。

然而,您的客户端可以选择使用CryptoAPI函数开启凭证,取出常见的名称,并且对照预期接收之公开凭证所知的常见名称。假如名称不相符,客户端可以选择关闭连线,因为服务器可能已经被恶意的第叁者欺骗。尽管Windows 2000会为您执行这个测试动作,但是早期的Windows版本并不这样做,所以它是个应该被熟悉的好技术。

您必须确信CA不会使用相同的名称发行凭证给多方,因此从这个凭证提供者到下一个的区域原则是可以改变的。


说明

凭证的常见名称是个简单的文字字串,不过请记得使用凭证授权单位的私密金钥来对凭证做签章。这表示当您使用CA的公开金钥开启凭证时,凭证上的资讯并没有被改变。浏览器软件使用凭证的常见名称执行对凭证及浏览器连结的URL做字元对字元的比较。假如名称不相符,浏览器会认为安全性已经被破坏。


  •  方案二 除了服务器并非使用本机帐户执行,而是用只为服务建立的特殊帐户执行以外,一切都与方案一所述相同。就凭证而言,唯一的主要差别即是您(服务器的管理者)要将凭证储存在服务器帐户的个人存放档档中。这是因为服务器的帐户可能不是主控机器的管理者,这样一来将无法存取机器凭证存放档。所以您必须知道如何存取个人帐户。
     
  •  方案叁 不管您的服务器是否存取机器帐户或个人帐户,皆必须考虑到第叁个方案。假如您的服务器从您的客户端要求凭证,则您的客户端跟服务器一样,必须采用类似的凭证寻找方法。
     

当连线成功时,您的客户端将在它的机器上查询凭证,并使用那个凭证初始SSL会谈。因为大部分的客户端软件会在互动式使用者帐户下执行,您的客户端软件可能必须知道如何经由常见的名称而从个人存放档中寻找凭证,然后再跨越线路传送它。

假如您将要编写处理任何一个方案的软件,这里是您必须能够用CryptoAPI执行的工作:

  1. 开启凭证存放档。
  2. 经由常见的名称查询凭证。
  3. 将凭证解密(传递给您的)。
  4. 查询解密凭证的常见名称(与所知的名称作比较)。

您可以使用CryptoAPI执行所有工作。然而,有一个没有提到的必要工作-取得凭证。

取得凭证
 

取得凭证是个复杂的主题,主要是因为它可以使用不同的方法执行。此处我将给您一些开始的要点,但是您必须研究您的选择,并决定哪个方式对您最好。以下有两个主要的方法:

  1. 从公开凭证授权单位购买凭证。
  2. 使用Microsoft凭证服务执行您自己的凭证授权单位。

第一个方法牵涉到与第叁方的互动,例如VeriSign或Thawte等等,为您的服务器软件取得凭证。这些第叁方将储存有关您的公司及凭证用途的资讯,然后它们将给您一个经由它们签章过的凭证,这是免费的。

这个方案的好处是已知的凭证授权单位大部份预设被操作系统所信任。所以客户端软件(或使用者)在识别您服务器的凭证时,不会有任何额外的麻烦。

某些公开的凭证授权单位很乐意发给您经授权单位签章的测试用凭证,您可以用它来测试他们所提供的各项服务。某些公司可以免费提供这些暂时的凭证。

第二个方法则需要执行Microsoft的凭证服务。这个方法很好,只是您必须确定让您的客户端软件信任经您设定的凭证授权单位。如果您的客户端及服务器在相同的企业环境中执行,则您可能会想采用这个方法。假如您的客户端及服务器在Internet环境中执行时,您仍然可以采用这个方法,但是您的客户端软件必须知道如何帮助使用者经由您的CA建立信任关系。

一旦拥有凭证后就可以使用Microsoft管理主控台(MMC)的凭证嵌入式管理单元从某个系统管理凭证或将它移到另一个系统中。这可让您在不同的情形下,使用单一的凭证来测试运作情形。例如,您可以建立一个服务器凭证,把它拖拉到机器的Personal凭证资料夹中,然后在机器帐户下测试您的服务器。以后当您决定在使用者帐户下执行您的服务器程序时,您便可以在使用者嵌入式管理单元中把凭证移到该使用者帐户下的Personal凭证资料夹中。

使用CryptoAPI开启凭证存放档
 

凭证存放档经由文字名称定义。机器帐户或使用者帐户的个人存放档被命名为「MY」。您可以使用CertOpenStore函数来开启凭证存放档:

HCERTSTORE WINAPI CertOpenStore(
	PCSTR	pszStoreProvider,
	DWORD	dwMsgAndCertEncodingType,
	HCRYPTPROV	hCryptProv,
	DWORD	dwFlags,
	const void	*pvPara);

这个函数非常有弹性,可让您使用多种方法来开启及建立存放档。我们将集中在讨论机器上或使用者帐户的个人存放档。这两个存放档即是 系统存放档(system stores) 。为了开启系统存放档,您必须传递CERT_STORE_PROV_SYSTEM给pszStoreProvider参数(如果您使用ANSI,则应传递CERT_STORE_ PROV_SYSTEM_A)。假如您对处理其他凭证存放档的主题有兴趣,例如存放档只保留在内存中等,请参考《Platform SDK》文件的内容。

您应该只传递X509_ASN_ENCODING|PKCS_7_ASN_ENCODING给dwMsgAndCertEncodingType参数。此时并没有支援其他的加密类型。您应该传递NULL给hCryptProv参数,以指示预设之密码提供者的用法。

dwFlags参数是让您指出要开启哪种存放档类型的地方。表12-8显示了可被dwFlags参数选择的值。

 表12-8 可让CertOpenStore之Flags参数选择的值
标记 说明
CERT_SYSTEM_STORE_CURRENT_SERVICE 指出现行服务执行所在地的帐户存放档
CERT_SYSTEM_STORE_CURRENT_USER 指出呼叫程序代码之当前使用者帐户存放档
CERT_SYSTEM_STORE_LOCAL_MACHINE 指出本地端机器的存放档

pvPara参数提供用来寻找存放档的特殊资讯。对于系统存放档,您应该传递指出存放档名称的字串。如果您使用个人存放档,则应该使用文字「MY」。

若您传递了一个无法被系统识别的名称,系统将为您建立这个存放档。这会是个建立机器上之逻辑群组凭证的便利方法。CertOpenStore函数会传回HCERTSTORE变数值,它是个凭证存放档的handle。当您使用完凭证存放档时,应该使用CertCloseStore函数关闭这个handle:

BOOL WINAPI CertCloseStore(
	HCERTSTORE	hCertStore,
	DWORD	dwFlags);

通常会传递0给dwFlags值。

搜寻凭证
 

凭证中包含丰富的资讯数量,CryptoAPI可让您使用储藏在凭证中的任何资讯来查询凭证。然而,一旦您在处理查询时没有经过明确的设定,便会使凭证中的大部分资讯不具唯一性。通常会透过凭证的常见名称参考它们,所以您应该用它们的常见名称来查询。

凭证的常见名称即是凭证的 属性 。在存放档中查询凭证时,可以使用一个或多个属性做为查询的准则。您可以为每个属性建立一个结构阵列,然后把它们传递到CertFindCertificateInStore函数中:

PCCERT_CONTEXT WINAPI CertFindCertificateInStore(
	HCERTSTORE	hCertStore,
	DWORD	dwCertEncodingType,
	DWORD	dwFindFlags,
	DWORD	dwFindType,
	const void	*pvFindPara,
	PCCERT_CONTEXT	pPrevCertContext);

hCertStore参数是个开启存放档的handle,其中包含凭证。dwCertEncoding Type参数应该一直是X509_ASN_ENCODING|PKCS_7_ASN_ENCODING值。大部分的情况下,您应该传递0给dwFindFlags参数,请查阅《Platform SDK》文件以取得更详细的资讯。

dwFindType参数指出您想要用来搜寻凭证的方法。如果您要经由一个或多个凭证的属性搜寻凭证时,应该传递CERT_FIND_SUBJECT_ATTR值。


说明

您也可以使用许多其他的方法去搜寻凭证。例如,您确信为开启存放档中唯一的凭证时,可以传递CERT_FIND_ANY给dwFindType参数。搜寻的选项内容可在《Platform SDK》文件中查询。在本章中,我们会集中在常使用的方法上。


您可以传递实际的搜寻准则给pvFindPara参数。假如您要经由它的属性来查询,则可以使用CERT_FIND_SUBJECT_ATTR标记搜寻凭证,您应传递一个指向CERT_RDN结构的指标给这个参数。

CertFindCertificateInStore可以被用来列举存放档中的凭证。pPrevCertContext参数指出最后取得的内容。您应该传递NULL给第一个凭证的内容,或当您在查询唯一的凭证时也应传递NULL给它。

假如您的搜寻执行成功时,CertFindCertificateInStore将传回指向凭证内容结构的指标。当您用完这个结构后,必须使用CertFreeCertificateContext函数释放它:

BOOL WINAPI CertFreeCertificateContext(
	PCCERT_CONTEXT pCertContext);

当在呼叫CertFindCertificateInStore函数时,您应传递CERT_RDN结构以做为pvFindPara结构值,其定义如下:

typedef struct _CERT_RDN {
	DWORD	cRDNAttr;
	PCERT_RDN_ATTR	rgRDNAttr;
} CERT_RDN;

cRDNAttr成员指出被您用来搜寻凭证的属性数量。rgRDNAttr成员是个指向已填满属性资讯的CERT_RDN_ATTR结构阵列指标。以下是CERT_RDN_ATTR的定义:

typedef struct _CERT_RDN_ATTR {
	PSTR	pszObjId;
	DWORD	dwValueType;
	CERT_RDN_VALUE_BLOB	Value;
} CERT_RDN_ATTR;

这个结构设计得非常有弹性,因为它必须包含许多能在凭证中制造属性的资料类型。pszObjId成员是我们非常关心的属性ID。对于凭证的常见名称,您应该使用szOID_COMMON_NAME值。dwValueType成员指出这个值的资料类型。应设定常见名称的值为CERT_RDN_PRINTABLE_STRING(注意,您必须一直使用ANSI字串)。

Value成员是个Blob结构,它包含了两个成员:即cbData及pbData,分别是资料的大小及指向资料的指标。


说明

有些属性类型及资料类型可在《Platform SDK》文件中找到详细的内容。如果您想要了解pupulate的结构,可能必须使用另一个方法来搜寻凭证,因为文件中并没有描述。例如,您可以开启凭证并查看它的RDN值,以便将来可以使用类似的资料查询凭证。


以下的程序代码范例显示了截至目前为止已讨论过的凭证函数用法。它会开启本地端机器的个人存放档,并且查询名称为「Jason's Test Certificate」的凭证。


说明

尽管「Jason's Test Certificate」是一个绝对合法的凭证名称,但并不代表一般的用法。通常凭证会依照服务器的网路位置命名,这样一来,客户端软件便可以把凭证的网路位置名称与它试图初始之安全工作阶段的网路位置作比较。


// 开启本地端机器的个人凭证存放档
HCERTSTORE hMyCertStore =
	CertOpenStore(CERT_STORE_PROV_SYSTEM_A,
		X509_ASN_ENCODING | PKCS_7_ASN_ENCODING,
		0,
		CERT_SYSTEM_STORE_LOCAL_MACHINE,
		"MY");
if(hMyCertStore==NULL){
	// 错误
}

// 填入凭证名称的属性结构
PSTR pszCommonName = "Jason's Test Certificate";
CERT_RDN_ATTR certRDNAttr[1];
certRDNAttr[0].pszObjId = szOID_COMMON_NAME;
certRDNAttr[0].dwValueType = CERT_RDN_PRINTABLE_STRING;
certRDNAttr[0].Value.pbData = (PBYTE)pszCommonName;
certRDNAttr[0].Value.cbData = lstrlen(pszCommonName);
CERT_RDN certRDN = {1, certRDNAttr};

// 搜寻凭证的内容
PCCERT_CONTEXT pCertContext =
	CertFindCertificateInStore(hMyCertStore,
		X509_ASN_ENCODING | PKCS_7_ASN_ENCODING,
		0,
		CERT_FIND_SUBJECT_ATTR,
		&certRDN,
		NULL);

if (pCertContext == NULL){
	// 错误
}

这个执行查询凭证工作的程序代码相当简短,不过您可能会对程序代码之后所显示的新概念感到惊讶。假如服务器向客户端验证它自己,而不考虑客户端验证时,先前的程序代码即是服务器所要担心的所有凭证程序代码。剩下的工作交由SSL处理,我们将立即讨论这部份。

然而,验证客户端凭证的客户端或服务器程序代码必须开启凭证内容,并找到与凭证相关的资讯。特别是程序代码必须使用它的常见名称来识别凭证。

读取凭证资讯
 

您的软件通常会读取经由使用SSL远端原则所接收的凭证资讯。然而,情形并非一直如此,您的软件可以从CertFindCertificateInStore函数接收到的凭证中读取资讯。这两个方法是相同的,您将从SSL传回的PCCERT_CONTEXT结构或是CryptoAPI开始。以下是系统从凭证读取属性资讯时必须采用的步骤:

  1. 将凭证的「主要(subject)」Blob解密。
  2. 找到您想读取的RDN属性。
  3. 取得属性资讯。

虽然CryptoAPI函数提供您采用这叁个步骤的方法,然而有更高阶的函数可用来从凭证中取得更有用的资讯。要从凭证中取得常见名称(或任何其他的名称资讯)时,您应该使用CertGetNameString函数:

DWORD WINAPI CertGetNameString(
	PCCERT_CONTEXT	pCertContext,
	DWORD	dwType,
	DWORD	dwFlags,
	void	*pvTypePara,
	PTSTR	pszNameString,
	DWORD	cchNameString);

pCertContext参数是指向您要取得名称的凭证内容指标。dwType参数指出您要传回的名称类型。为了撷取凭证的名称属性,您应传递CERT_NAME_ATTR_ TYPE值。为了取得凭证的易记名称,则要传递CERT_NAME_FRIENDLY_ DISPLAY_ID值。

传递到dwType的值也指出您传递资料给pvTypePara参数的目的。例如,就CERT_NAME_FRIENDLY_DISPLAY_ID而言,您应传递NULL值。就CERT_NAME_ATTR_TYPE而言,则要传递字串,它表示您要传回之物件的物件识别项(OID);szOID_COMMON_NAME被用来取得常见的名称字串。有关更多dwType及pvTypePara的用法,请参阅《Platform SDK》文件。

您通常会传递0给CertGetNameString的dwFlags参数;然而,如果您想要搜寻和凭证的「发行者」有关,而非凭证主体本身的资讯时,您可以传递CERT_NAME_ISSUER_FLAG给dwFlags参数。

CertGetNameString的pszNameString及cchNameString参数,分别指出接收字串名称的缓冲器及缓冲器大小。如果您传递NULL及0给这些参数,CertGetNameString会传回缓冲器要求的大小。

以下的程序代码片段显示此函数被用来撷取凭证内容之常见名称的用法:

TCHAR szCommonName[1024]; 
ULONG lBytes =
	CertGetNameString(pCertContext,
		CERT_NAME_ATTR_TYPE, 0,
		szOID_COMMON_NAME,
		szCommonName, 1024);
if(lBytes != 1){  // 如果不是空字串
	// 对这个字串作些处理
}

使用截至目前为止本章所提及概念,可让您充份地管理经由SSL通讯所需的凭证。您也可以在本章的SSLChat范例应用程序中找到相关主题的完整实作,我们将在本章稍后谈论此部份。

SSL与SSPI
 

我们已经利用Windows信任帐户讨论过使用NTLM及Kerberos之SSPI及验证的部份。您也可以经由数位凭证使用SSPI去做验证的动作。经由使用SSPI及SSL的Schannel安全性提供者可以达成此工作。若您打算使用SSL,则您的程序代码中必定要包含Security.h及SChannel.h标头档,并且应该把您的专案与Secur32.lib程序库文件做连结。

和目前为止曾讨论的通讯协定不同,SSL比较不集中于从客户端至服务器的验证部份,通常从服务器到客户端验证的部份开始。从客户端至服务器验证的方式也是可能的,在这种情况下,可以选择使用模拟(Impersonation)的方式。首先让我们看一些常见的方案。客户端想要与某些站台连接时,最大的可能是在Internet上。在传送站台的关键性资讯前,它想要证明自己正在与所要求的实体通讯。此时服务器会传送客户端的凭证。此凭证已被签章,其中并包含了公开/私密金钥之公开金钥。

客户端可以辨认该凭证是由谁发行的,并帮助客户端决定是否应信任这个凭证。假如它信任签署凭证的CA,则客户端可以相信凭证内的资讯是有效的。凭证经过验证后,客户端即可以读取凭证资讯,例如服务器的URL等,并把这些资讯与正在与它通讯的服务器做比较。假如对照的结果符合,则客户端可以确信它正在与正确的服务器联系。


说明

有许多的凭证模组议题尚未被业界开发。客户端软件如何能够真正信任凭证内的资讯?有较好的方法可以把信任的重担从客户端软件转移到使用者身上吗?如何转达资讯?什么样的根授权单位应被信任?使用者可以选择这些授权单位吗?所有的授权单位应该被凭证的任何类型信任吗?凭证授权单位也应该是特定群组的业界授权单位,例如doctors' offices或预约的传递者吗?这些问题的回答将显露出成熟的技术。从现在开始,我们将集中在技术的讨论上。


根据您设计的安全通讯环境,可以决定您自己的凭证授权单位。Microsoft凭证服务可让您发行自己的凭证,而且只要您的客户端信任您的CA,就可以建立符合确切标准的凭证。假如您是自己的凭证授权单位,您甚至可以编写包含信任您的CA之内部根凭证的客户端软件。当客户端连接到您的服务器时,可以使用这个内含的信任来启始一个客户端的工作阶段,然后再为客户端产生凭证(在那里,客户端会产生私密金钥并传递公开金钥给您的服务器)。您的服务器会使用公开金钥从您自己的CA产生凭证,然后储藏这个资讯。此后每当客户端连接到您的服务器时,便可以使用彼此的公开金钥,以凭证的形式,快速地对彼此验证、协商工作阶段金钥,并开始交易。

假定客户端已经验证了服务器的凭证,客户端可以从凭证中取出公开金钥并且用它来对传送到服务器的对称性工作阶段金钥做加密。服务器是唯一持有对称私密金钥的实体,所以它可以将此工作阶段金钥做解密。此时客户端及服务器可以确定它们已经可以与适当的另一方做安全地通讯。

在标准情形下所要求的安全通讯是使浏览器或其他客户端跨越线路传送信用卡或其他敏感的资讯到服务器端。您可以使用SSPI为这种交易实作SSL机制。

SSL程序设计
 

您已经知道了许多应用于SSL通讯的相关SSPI。本节假定您已经具备使用Kerberos的SSPI及NTLM的知识;我们要开始建立目前为止本章所提及之大量资讯。

使用NTLM及Kerberos通讯协定时,您会在通讯的客户端及服务器端呼叫AcquireCredentialsHandle函数。然而,SSL必须经由传入特定的SSP验证结构,即SCHANNEL_CRED,至少为通讯的一端建立凭证。SCHANNEL_CRED结构与SEC_WINNT_AUTH_ IDENTITY结构(本章稍早的时候讨论过)相似,除了SCHANNEL_CRED包括凭证资讯外,它提供了使用者名称、密码及网域名称等资讯。

SCHANNEL_CRED结构的定义如下:

typedef struct _SCHANNEL_CRED {
	DWORD	dwVersion;
	DWORD	cCreds;
	PCCERT_CONTEXT	*paCred;
	HCERTSTORE	hRootStore;
	DWORD	cMappers;
	struct _HMAPPER	**aphMappers;
	DWORD	cSupportedAlgs;
	ALG_ID *	palgSupportedAlgs;
	DWORD	grbitEnabledProtocols;
	DWORD	dwMinimumCipherStrength;
	DWORD	dwMaximumCipherStrength;
	DWORD	dwSessionLifespan;
	DWORD	dwFlags;
	DWORD	reserved;
} SCHANNEL_CRED;

服务器通常在将它传递到AcquireCredentialsHandle之前即使用凭证来初始SCHANNEL_CRED。以下的程序代码片段显示使用它的方法:

// 开启本地端机器的个人凭证存放档
HCERTSTORE hMyCertStore =
	CertOpenStore(
		CERT_STORE_PROV_SYSTEM_A,
		X509_ASN_ENCODING | PKCS_7_ASN_ENCODING,
		0,
		CERT_SYSTEM_STORE_LOCAL_MACHINE,
		"MY");
if(hMyCertStore==NULL){
	// 错误
}

// 填入凭证名称的属性结构
PSTR pszCommonName = "Jason's Test Certificate";
CERT_RDN_ATTR certRDNAttr[1];
certRDNAttr[0].pszObjId = szOID_COMMON_NAME;
certRDNAttr[0].dwValueType = CERT_RDN_PRINTABLE_STRING;
certRDNAttr[0].Value.pbData = (PBYTE)pszCommonName;
certRDNAttr[0].Value.cbData = lstrlen(pszCommonName);
CERT_RDN certRDN = {1, certRDNAttr};

// 搜寻凭证内容
PCCERT_CONTEXT pCertContext =
	CertFindCertificateInStore(
		hMyCertStore,
		X509_ASN_ENCODING | PKCS_7_ASN_ENCODING,
		0,
		CERT_FIND_SUBJECT_ATTR,
		&certRDN,
		NULL);
if (pCertContext == NULL){
	// 错误
}

// 填入凭证资讯到SCHANNEL_CRED变数
SCHANNEL_CRED sslCredentials = {0};
sslCredentials.dwVersion = SCHANNEL_CRED_VERSION;
sslCredentials.cCreds = 1;
sslCredentials.paCred = &pCertContext;
sslCredentials.grbitEnabledProtocols = SP_PROT_SSL3;

// 取得凭证handle
CredHandle hCredentials;
TimeStamp tsExpires;
SECURITY_STATUS ss =
	AcquireCredentialsHandle(
		NULL,
		UNISP_NAME,
		SECPKG_CRED_INBOUND,
		NULL,
		&sslCredentials,
		NULL, NULL,
		&hCredentials,
		&tsExpires );
if(ss != SEC_E_OK){
	// 错误
}

说明

请注意,这个程序代码使用了下一节即将讨论的凭证函数,即撷取凭证名称为「Jason's Test Certificate」的PCCERT_CONTEXT结构。



说明

当您使用凭证去取得经由呼叫AcquireCredentialsHandle函数所得到的凭证时,该凭证必须与隐含的目的相符。例如,对服务器来说,必须被签章的凭证可让服务器验证。否则Acquire CredentialsHandle函数会传回错误,并指出未知的凭证。


客户端会使用前面的方法,以凭证开始通讯。客户端使用NULL凭证也是常见的,它指出以匿名的方式向服务器提出验证。以下的程序代码片段显示其方式:

SCHANNEL_CRED sslCredentials = {0};
sslCredentials.dwVersion = SCHANNEL_CRED_VERSION;
sslCredentials.grbitEnabledProtocols = SP_PROT_SSL3;
sslCredentials.dwFlags =
	SCH_CRED_NO_DEFAULT_CREDS|SCH_CRED_MANUAL_CRED_VALIDATION;
CredHandle hCredentials;
TimeStamp tsExpires;
SECURITY_STATUS ss =
	AcquireCredentialsHandle(
		NULL,
		UNISP_NAME,
		SECPKG_CRED_OUTBOUND,
		NULL,
		&sslCredentials,
		NULL,
		NULL,
		&hCredentials,
		&tsExpires);
if(ss != SEC_E_OK){
	// 错误
}

至目前为止,使用SSL与我们习惯使用的验证过程并没有很大的不同。然而,从此以后事情会开始有点改变,即使客户端仍然呼叫InitializeSecurityContext函数,而服务器仍旧呼叫AcceptSecurityContext函数也是一样。

SSL是一个资料流(Stream)
 

和我们讨论过的其他通讯协定不同,在通讯前,您应该包装即将被传送的Blobs,并从InitializeSecurityContext以及AcceptSecurityContext函数中传递,SSL预期您的软件并没有包装Blobs!当您从InitializeSecurityContext中接收Blob时,只需传送逐字的资料取代传送资讯大小及资料即可。


说明

尽管SSL预期传送及接收逐字的资料,但是没有任何事情可以阻止您对「中继通讯协定(meta protocol)」的实作,以指出Blob大小。这样做可以大大地简化您的SSL程序代码,虽然技术上您不需采用HTTPS规格。假如您要跨越线路传递附加的资料,您的客户端或服务器大概不能与实作HTTPS的客户端或服务器通讯,通常是Web浏览器及Web服务器。


您可能会纳闷如何从收到通讯的大小中得知要读取多少资料,答案是无法得知。SSL通讯使用的是一个资料流。以下的情形说明了SSL在资讯流程上的影响。它显示如何读取资讯到完成讯息为止的情形。

  1. 一端(如客户端)跨越线路传送未修改的Blob。
  2. 接收端读取可用的资讯并将它传递到它的「blob-handling」函数中(在我们的范例中,服务器会传递资料到AcceptSecurityContext函数)。
  3. 假如接收端没有从线路读取足够的资讯以完成交易流程,blob-handling函数会记录SEC_E_INCOMPLETE_MESSAGE。
  4. 假如未完成的讯息值被传回,您必须储存已经拥有的资料,并回到线路上尽可能地接收附加资料,直到取得除了SEC_E_INCOMPLETE_MESSAGE以外的传回值为止。

您可能已了解除了接收过少资料的可能性外,SSL的资料流性质产生了复杂化的情形。您的软件可能跨越线路读取了 过多的 资讯。在这个情形下, AcceptSecurityContext  InitializeSecurityContext 函数会记录您已为将来使用所传递之必须储存的附加资料(大概在跨越线路读取更多资料之后)。

如您所见,还有更多的方案等待使用SSL。以下是两种必须随时察觉的情况:

  1. 您的SSPI函数处理之资讯太少,所以必须接收更多资料。
  2. SSPI函数可能有太多资讯,所以必须为将来呼叫SSPI函数而储存资讯
    A. 您必须从线路上取得更多资讯。
    B. 您不必从线路上取得更多资讯。

请注意,第二个方案中有两个子方案。方案2A是预期中的,方案2B则可能令人感到惊讶。在两种情况下,AcceptSecurityContext或InitializeSecurityContext函数会记录已经传入的附加资料,而其「持续性」则是必需的。然而在这些情况中,blob-handling函数并不会记录任何跨越线路所传回的Blob资料。所以您必须修改刚刚传入的缓冲器,然后再将它传回blob-handling函数中。

依照我的看法,这个拥有太多资料的方案最好不要从SSPI显示给应用程序,因为AcceptSecurityContext及InitializeSecurityContext函数皆拥有处理资料所需的全部资讯。这个函数只有在没有足够资料可用来处理完整的讯息时才需要停止执行。

处理附加资料的问题没有您想像的那么容易解决。请考虑这个情形:您在线路上收到一个Blob,它的内部分成叁个「区段(Section)」,最后一区段是不完整的,所以您必须从线路上取得更多资料。假设您把这个Blob传递到AcceptSecurityContext函数中,它会处理Blob内的前两个区段。当它完成时,发现最后一个区段是不完整的,而且需要更多资料。然而,它并不需要让您的应用程序回到线路上取得更多资料并传送 整个Blob 回blob-handling函数,因为它已经处理Blob内的前两区段。

为了避免这样的情况产生,由系统的设计者决定Blob内的任一节或子节是否应被处理,不管是否有足够的附加资料可以传递给更多的程序, AcceptSecurityContext及InitializeSecurityContext函数皆会返回。这样一来您便可以察觉更多的附加资料、调整您的缓冲器并重新呼叫函数。

因为附加资料的环境很复杂,而且可能会有读取太少对SSPI有用之资料的情形,所以最好对所有与客户端的通讯分配及使用单一的缓冲器。这个缓冲器应该大到足够持有用SSL协定跨越线路所传输的最大单一讯息。要找出这个大小,您应该在开始通讯之前呼叫QuerySecurityPackageInfo函数:

SECURITY_STATUS QuerySecurityPackageInfo(
	SEC_CHAR	*pszPackageName,
	PSecPkgInfo	*ppPackageInfo);

此函数传回包含SecPkgInfo结构的缓冲器,当您使用完时,必须使用FreeContextBuffer函数来释放它:

typedef struct _SecPkgInfo {
	ULONG	fCapabilities;	// 套件的功能标记
	USHORT	wVersion;	// 驱动程序版本
	USHORT	wRPCID;	// 由系统使用
	ULONG	cbMaxToken;	// 封包的最大讯息大小
	SEC_CHAR	*Name;	// 文字名称
	SEC_CHAR	*Comment;	// 注释
} SecPkgInfo;

如您所见,QuerySecurityPackageInfo函数传回令人关注的资讯到SecPkgInfo结构中。然而,通常您只需要使用cbMaxToken值指出使用SSL协商验证时应该使用的最小缓冲器。在我们先前对SSPI的讨论中,我实作了用来在客户端及服务器之间执行验证交握的函数。以下的程序代码显示了执行类似SSL工作的程序代码。您将会注意到,这些函数取得了一个指向缓冲器的指标及该缓冲器的大小。它们也取得一个指出有多少资料已经存在缓冲器里的参数。这些参数提供给SSL的附加资料方案。以下是SSLServerHandshakeAuth函数原型:

BOOL SSLServerHandshakeAuth(
	CredHandle* phCredentials,
	PULONG plAttributes,
	PCtxtHandle phContext,
	PBYTE pbExtraData,
	PULONG pcbExtraData,
	ULONG lSizeExtraDataBuf){

	BOOL fSuccess = FALSE;
	__try
	{
		// 就区域变数设定一个缓冲器
		ULONG lEndBufIndex = *pcbExtraData;
		ULONG lBufMaxSize = lSizeExtraDataBuf;
		PBYTE pbTokenBuf = pbExtraData;

		// 宣告输出及输入缓冲器
		SecBuffer secBufferIn[3]={0};
		SecBufferDesc secBufDescriptorIn;

		SecBuffer secBufferOut;
		SecBufferDesc secBufDescriptorOut;

		// 设定回圈状态资讯
		BOOL fFirstPass = TRUE;
		SECURITY_STATUS ss = SEC_I_CONTINUE_NEEDED;
		while (ss == SEC_I_CONTINUE_NEEDED ||
			ss == SEC_E_INCOMPLETE_MESSAGE){
			// 每次操作可以读取多少资料
			ULONG lReadBuffSize;
			// 重新设定,如果我们不执行一个「不完整的」回圈 
			if (ss != SEC_E_INCOMPLETE_MESSAGE){
				// 为另一个「Blob交换」重新设定状态
				lEndBufIndex = 0;
				lReadBuffSize = lBufMaxSize;
			}

			// 从客户端接收Blob资料
			ReceiveData(pbTokenBuf+lEndBufIndex, &lReadBuffSize);

			// 这是我们迄今已经读取的部分
			lEndBufIndex += lReadBuffSize;

			// 设定我们的输入缓冲器
			secBufferIn[0].BufferType = SECBUFFER_TOKEN;
			secBufferIn[0].cbBuffer = lEndBufIndex;
			secBufferIn[0].pvBuffer = pbTokenBuf;

			// 变成一个SECBUFFER_EXTRA缓冲器的方法, 
			// 用来让我们知道后来是否含有附加资料
			secBufferIn[1].BufferType = SECBUFFER_EMPTY;
			secBufferIn[1].cbBuffer = 0;
			secBufferIn[1].pvBuffer = NULL;

			// 设定输入缓冲器描述项
			secBufDescriptorIn.ulVersion = SECBUFFER_VERSION;
			secBufDescriptorIn.cBuffers = 2;
			secBufDescriptorIn.pBuffers = secBufferIn;

			// 设定输出缓冲器(由SSPI分配)
			secBufferOut.BufferType = SECBUFFER_TOKEN;
			secBufferOut.cbBuffer = 0;
			secBufferOut.pvBuffer = NULL;

			// 设定输出缓冲器描述项
			secBufDescriptorOut.ulVersion = SECBUFFER_VERSION;
			secBufDescriptorOut.cBuffers = 1;
			secBufDescriptorOut.pBuffers = &secBufferOut;

			// 此内部回圈处理那些不要传送Blob资料的「持续情况」。
			// 这种情况下,仍然有很多「区段」存在最后必须处理的Blob
			// 项目中。
			BOOL fMoreSections;
			// 这个回圈处理所有缓冲器中完整的资料「区段」
			do {
				fMoreSections = FALSE;
				//Blob程序
				ss =
					AcceptSecurityContext(
						phCredentials,
						fFirstPass ? NULL : phContext,
						&secBufDescriptorIn,
						*plAttributes|
						ISC_REQ_ALLOCATE_MEMORY|ISC_REQ_STREAM,
						SECURITY_NATIVE_DREP,
						phContext,
						&secBufDescriptorOut,
						plAttributes,
						NULL);
				// 有更多「区段」要处理吗?
				if ((ss == SEC_I_CONTINUE_NEEDED) &&
					(secBufferOut.cbBuffer == 0)){
					fMoreSections = TRUE; //Set state to loop
					// 有多少资料被留下
					ULONG lExtraData = secBufferIn[1].cbBuffer;
					// 让我们移动资料回到缓冲器的开始处
					MoveMemory(pbTokenBuf,
						pbTokenBuf+(lEndBufIndex - lExtraData), lExtraData);
					// 我们的缓冲器中有多少资料
					lEndBufIndex = lExtraData;
					// 重新设定输入缓冲器
					secBufferIn[0].BufferType = SECBUFFER_TOKEN;
					secBufferIn[0].cbBuffer = lEndBufIndex;
					secBufferIn[0].pvBuffer = pbTokenBuf;

					secBufferIn[1].BufferType = SECBUFFER_EMPTY;
					secBufferIn[1].cbBuffer = 0;
					secBufferIn[1].pvBuffer = NULL;
				}
			}while(fMoreSections);
			// 下一次从线路上可以读取多少资料进来而不会超出缓冲器范围
			lReadBuffSize = lBufMaxSize - lEndBufIndex;

			if (ss != SEC_E_INCOMPLETE_MESSAGE){
				// 不再是第一次传递
				fFirstPass = FALSE;
			}

			// 有资料要传送吗?
			if (secBufferOut.cbBuffer != 0){
				// 传送它
				ULONG lOut = secBufferOut.cbBuffer;
				SendData(secBufferOut.pvBuffer, lOut);
				// 然后释放缓冲器
				FreeContextBuffer(secBufferOut.pvBuffer);
			}
		}

		if (ss == SEC_E_OK){
			// 假如有附加资料,这是被加密的应用层资料。我们将放入缓冲器,
			// 而应用层之后可以使用DecryptMessage解密
			int nIndex = 1;
			while(secBufferIn[nIndex].BufferType
				!= SECBUFFER_EXTRA && (nIndex-- != 0));
			if ((nIndex != -1) && (secBufferIn[nIndex].cbBuffer != 0)){
				*pcbExtraData = secBufferIn[nIndex].cbBuffer;
				PBYTE pbTempBuf = pbTokenBuf;
				pbTempBuf += (lEndBufIndex - *pcbExtraData);
				MoveMemory(pbExtraData, pbTempBuf, *pcbExtraData);
			}
			fSuccess = TRUE;
		}

	}__finally{}
	return (fSuccess);
}

以下是SSLClientHandshakeAuth函数的定义:

BOOL SSLClientHandshakeAuth(
	CredHandle* phCredentials,
	CredHandle* phCertCredentials,
	PULONG plAttributes,
	CtxtHandle* phContext,
	PTSTR pszServer,
	PBYTE pbExtraData,
	PULONG pcbExtraData,
	ULONG lSizeExtraDataBuf)
{
	BOOL fSuccess = FALSE;
	__try
	{
		// 设定我们自己的凭证handle副本
		CredHandle credsUse;
		CopyMemory(&credsUse, phCredentials, sizeof(credsUse));

		// 就区域变数的可读性设定缓冲器
		ULONG lEndBufIndex = *pcbExtraData;
		ULONG lBufMaxSize = lSizeExtraDataBuf;
		PBYTE pbData = pbExtraData;

		// 宣告输入及输出缓冲器
		SecBuffer secBufferOut;
		SecBufferDesc secBufDescriptorOut;

		SecBuffer secBufferIn[2];
		SecBufferDesc secBufDescriptorIn;

		// 设定回圈状态资讯
		BOOL fFirstPass = TRUE;
		SECURITY_STATUS ss = SEC_I_CONTINUE_NEEDED;
		while ((ss == SEC_I_CONTINUE_NEEDED) ||
			(ss == SEC_E_INCOMPLETE_MESSAGE)){
			// 每次操作可以读取多少资料
			ULONG lReadBuffSize;
			// 重新设定,假如我们不执行「不完整的」回圈
			if (ss !=SEC_E_INCOMPLETE_MESSAGE){
				// 为另一个Blob交换重新设定状态
				lEndBufIndex = 0;
				lReadBuffSize = lBufMaxSize;
			}

			// 有些东西我们只有在第一次传递后执行
			if (!fFirstPass){
				// 尽可能接收资料
				ReceiveData(pbData+lEndBufIndex, &lReadBuffSize);
				// 这是我们目前为止拥有的资料
				lEndBufIndex += lReadBuffSize;

				// 用我们当前的资料设定里面的缓冲器
				secBufferIn[0].BufferType = SECBUFFER_TOKEN;
				secBufferIn[0].cbBuffer = lEndBufIndex;
				secBufferIn[0].pvBuffer = pbData;

				// 变成一个SECBUFFER_EXTRA缓冲器,用来让
				// 我们知道其后是否有附加资料
				secBufferIn[1].BufferType = SECBUFFER_EMPTY;
				secBufferIn[1].cbBuffer = 0;
				secBufferIn[1].pvBuffer = NULL;

				// 设定输入缓冲器描述项
				secBufDescriptorIn.cBuffers = 2;
				secBufDescriptorIn.pBuffers = secBufferIn;
				secBufDescriptorIn.ulVersion = SECBUFFER_VERSION;
			}

			// 设定输出缓冲器(由SSPI分配)
			secBufferOut.BufferType = SECBUFFER_TOKEN;
			secBufferOut.cbBuffer = 0;
			secBufferOut.pvBuffer = NULL;

			// 设定输出缓冲器描述项
			secBufDescriptorOut.cBuffers = 1;
			secBufDescriptorOut.pBuffers = &secBufferOut;
			secBufDescriptorOut.ulVersion = SECBUFFER_VERSION;

			// 这个内部回圈处理那些没有要传送Blob资料的「持续情况」。
			// 这种情况下,仍然有更多「区段」在我们最后必须处理的Blob 
			// 项目。
			BOOL fNoOutBuffer;
			do {
				fNoOutBuffer = FALSE;
				// Blob处理程序
				ss =
					InitializeSecurityContext(
						&credsUse,
						fFirstPass ? NULL : phContext,
						fFirstPass ? pszServer : NULL,
						*plAttributes|
						ISC_REQ_ALLOCATE_MEMORY|ISC_REQ_STREAM,
						0,
						SECURITY_NATIVE_DREP,
						fFirstPass ? NULL : &secBufDescriptorIn,
						0,
						phContext,
						&secBufDescriptorOut,
						plAttributes,
						NULL);
				// 有更多「区段」要处理吗?
				if ((ss == SEC_I_CONTINUE_NEEDED) &&
					(secBufferOut.cbBuffer == 0)){
					fNoOutBuffer = TRUE; // 设定回圈状态
					// 有多少资料被留下
					ULONG lExtraData = secBufferIn[1].cbBuffer;
					// 我们要把资料移动回缓冲器的开始处
					MoveMemory(pbData,
						pbData+(lEndBufIndex - lExtraData), lExtraData);
					// 现在我们有新的lEndBufIndex
					lEndBufIndex = lExtraData;

					// 让我们重新设定输入缓冲器
					secBufferIn[0].BufferType = SECBUFFER_TOKEN;
					secBufferIn[0].cbBuffer = lEndBufIndex;
					secBufferIn[0].pvBuffer = pbData;

					secBufferIn[1].BufferType = SECBUFFER_EMPTY;
					secBufferIn[1].cbBuffer = 0;
					secBufferIn[1].pvBuffer = NULL;
				}
				if (ss == SEC_I_INCOMPLETE_CREDENTIALS){
					// 服务器要求凭证
					// 用凭证复制凭证
					// 通常,我们会在这里呼叫AcquireCredentialsHandle函数
					// 以获得新的凭证。
					// 然而,我们已经在这个范例函数中传入
					// 凭证。
					CopyMemory(&credsUse, phCertCredentials, sizeof(credsUse));

					// 此操作不需输入
					secBufDescriptorIn.cBuffers = 0;
					// 继续传输
					fNoOutBuffer = TRUE; // 设定回圈状态
				}
			}while(fNoOutBuffer);
			// 下一次从线路上可以读取多少资料进来而不会超出缓冲器范围
			lReadBuffSize = lBufMaxSize - lEndBufIndex;

			// 有资料要传送吗?
			if (secBufferOut.cbBuffer!=0){
				// 传送它
				ULONG lOut = secBufferOut.cbBuffer;
				SendData(secBufferOut.pvBuffer, lOut);
				// 然后释放输出缓冲器
				FreeContextBuffer(secBufferOut.pvBuffer );
			}

			if (ss != SEC_E_INCOMPLETE_MESSAGE){
				fFirstPass = FALSE;
			}
		}

		if(ss == SEC_E_OK){
			int nIndex = 1;
			while(secBufferIn[nIndex].BufferType
				!= SECBUFFER_EXTRA && (nIndex-- != 0));
			if((nIndex !=-1)&&(secBufferIn [nIndex ].cbBuffer !=0)){
				*pcbExtraData = secBufferIn[nIndex].cbBuffer;
				PBYTE pbTempBuf = pbData;
				pbTempBuf += (lEndBufIndex - *pcbExtraData);
				MoveMemory(pbExtraData, pbTempBuf, *pcbExtraData);
			}
			fSuccess = TRUE;
		}
	}__finally{}
	return (fSuccess);
}

这些函数有几个方法与其Kerberos及NTLM副本不同。假如产生这些情形的话,这些函数会检查SEC_E_INCOMPLETE_MESSAGE的传回值,并且持续建立讯息缓冲器。

这些函数也与其副本不同,客户端函数会取得两个凭证handles。一个是匿名的凭证handle,另一个则是用客户端凭证建立的凭证handle(假如您没有客户端凭证,可以传递匿名handle的位址给他们)。这是因为客户端先提出它的匿名凭证,只有在服务器要求彼此验证时才会交换凭证。客户端会经由SEC_I_ INCOMPLETE_CREDENTIALS的回传值得知这个请求。


说明

实际状况中之客户端应用程序执行时,客户端可能不会搜寻凭证以及呼叫AcquireCredentialsHandle函数,直到它得知服务器要求彼此验证为止。我所列的范例函数皆假定使用客户端凭证的可能性,主要是简化范例之复杂逻辑的方法。



说明

如您所见的,SSL的细节使得情况更加复杂!然而,一旦您的验证程序完成后,通讯的一端或两端皆会持有另一端的凭证资讯。您可以经由呼叫QueryContextAttributes函数,使用完整的内容并传递SECPKG_ATTR_REMOTE_CERT_CONTEXT值及指标到PCCERT_CONTEXT结构中,以撷取凭证资讯。

当您用完从SSL环境中撷取的凭证内容时,释放凭证内容是您的责任。可以使用CertFreeCertificateContext函数达成。


在您撷取凭证内容后,可以使用前面章节中讨论的CryptoAPI函数从远端原则的凭证中取出资讯。

以下所列之程序代码范例显示撷取凭证资讯的方法。此程序代码假定一个完整的内容,并且以存放在szNameBuf缓冲器中的远端凭证名称作为结束。

// 取得服务器的凭证
PCCERT_CONTEXT pCertContext = NULL;
ss = QueryContextAttributes(&hContext, SECPKG_ATTR_REMOTE_CERT_CONTEXT,
	(PVOID)&pCertContext);
if(ss != SEC_E_OK){
	// 错误
}

// 找到我们从凭证解码的区块大小
ULONG lSize = 0;
CryptDecodeObject(
	X509_ASN_ENCODING | PKCS_7_ASN_ENCODING,
	X509_NAME,
	pCertContext->pCertInfo->Subject.pbData, // Data to decode
	pCertContext->pCertInfo->Subject.cbData, // Size of data
	0, NULL, &lSize);

// 分配内存给区块
CERT_NAME_INFO* pcertNameInfo = (CERT_NAME_INFO*)alloca(lSize);

// 实际从凭证中将主要区块解码
if(!CryptDecodeObject(
	X509_ASN_ENCODING | PKCS_7_ASN_ENCODING,
	X509_NAME,
	pCertContext->pCertInfo->Subject.pbData, // Data to decode
	pCertContext->pCertInfo->Subject.cbData, // Size of data
	0, pcertNameInfo, &lSize)){
	// 错误
}

// 查询凭证的常见名称属性
PCERT_RDN_ATTR pcertRDNAttr =
	CertFindRDNAttr(szOID_COMMON_NAME, pcertNameInfo);
if(pcertRDNAttr == NULL){
	// 错误
}

// 将CERT_RDN_ATTR结构中的资讯转换成为字串以便在您的应用程序中使用
TCHAR szNameBuf[1024];
if(CertRDNValueToStr(CERT_RDN_PRINTABLE_STRING,
	&pcertRDNAttr->Value, szNameBuf, 1024) == 0){
	// 错误
}

讯息的加密及解密
 

如同您所猜想的,由于SSL的资料流导向方法,使得加密过程也有点复杂。然而,一旦您熟悉从一个接收函数组的末端接收附加资料的方法后,加密及解密即是可达成的任务。

以下的两个函数说明了传送及接收加密讯息的方法。第一个函数为SendEncryptedMessage:

BOOL SendEncryptedMessage(CtxtHandle*phContext, 
	PVOID pvData, ULONG lSize){
	BOOL fSuccess = FALSE;
	__try
	{
		SecPkgContext_StreamSizes Sizes;
		// 取得资料流资料的属性
		SECURITY_STATUS ss =
			QueryContextAttributes(
				phContext,
				SECPKG_ATTR_STREAM_SIZES,
				&Sizes);
		if (ss != SEC_E_OK){
			__leave;
		}

		// 能够处理这么多的资料吗?
		if (lSize > Sizes.cbMaximumMessage){
			__leave;
		}

		// 这个缓冲器将成为标头,加上讯息及档尾的部份
		ULONG lIOBufferLength = Sizes.cbHeader +
			Sizes.cbMaximumMessage +
			Sizes.cbTrailer;	
		PBYTE pbIOBuffer = (PBYTE)alloca(lIOBufferLength);
		if (pbIOBuffer == NULL){
			__leave;
		}

		// 这个资料在标头之后复制到缓冲器中
		CopyMemory(pbIOBuffer+Sizes.cbHeader, (PBYTE)pvData, lSize);

		SecBuffer Buffers[3];
		// 设定缓冲器中的标头
		Buffers[0].BufferType	= SECBUFFER_STREAM_HEADER;
		Buffers[0].pvBuffer	= pbIOBuffer;
		Buffers[0].cbBuffer	= Sizes.cbHeader;

		// 设定缓冲器中的资料
		Buffers[1].BufferType	= SECBUFFER_DATA;
		Buffers[1].pvBuffer	= pbIOBuffer + Sizes.cbHeader;
		Buffers[1].cbBuffer	= lSize;

		// 设定缓冲器中的档尾
		Buffers[2].BufferType	= SECBUFFER_STREAM_TRAILER;
		Buffers[2].pvBuffer	= pbIOBuffer + Sizes.cbHeader + lSize;
		Buffers[2].cbBuffer	= Sizes.cbTrailer;

		// 设定缓冲器描述项
		SecBufferDesc secBufDescIn;
		secBufDescIn.ulVersion	= SECBUFFER_VERSION;
		secBufDescIn.cBuffers	= 3;
		secBufDescIn.pBuffers	= Buffers;

		// 将资料加密
		ss = EncryptMessage(phContext, 0, &secBufDescIn, 0);
		if (ss != SEC_E_OK){
			__leave;
		}

		// 在一个区块(Chunk)中传送叁个缓冲器
		ULONG lOut = Buffers[0].cbBuffer + Buffers[1].cbBuffer +
			Buffers[2].cbBuffer;
		SendData(pbIOBuffer, lOut);

		fSuccess = TRUE;
	}__finally{}
	return (fSuccess);
}

以下为GetEncryptedMessage函数的内容:
<程序代码>
PVOID GetEncryptedMessage(
	CtxtHandle* phContext,
	PULONG plSize,
	PBYTE* ppbExtraData,
	PULONG pcbExtraData,
	ULONG lSizeExtraDataBuf,
	BOOL* pfReneg){

	PVOID pDecrypMsg = NULL;
	*pfReneg = FALSE;

	__try
	{
		// 宣告缓冲器描述项
		SecBufferDesc SecBuffDesc = {0};
		// 宣告缓冲器
		SecBuffer      Buffers[4] == {0};
		// 附加到达的资料
		PBYTE pbData = *ppbExtraData;
		ULONG cbData = *pcbExtraData;

		SECURITY_STATUS ss = SEC_E_INCOMPLETE_MESSAGE;
		do
		{
			// 设定指向附加资料的初始资料缓冲器
			Buffers[0].BufferType = SECBUFFER_DATA;
			Buffers[0].pvBuffer = pbData;
			Buffers[0].cbBuffer = cbData;

			// 设定叁个空的输出缓冲器
			Buffers[1].BufferType = SECBUFFER_EMPTY;
			Buffers[2].BufferType = SECBUFFER_EMPTY;
			Buffers[3].BufferType = SECBUFFER_EMPTY;

			// 设定描述项
			SecBuffDesc.ulVersion	= SECBUFFER_VERSION;
			SecBuffDesc.cBuffers		= 4;
			SecBuffDesc.pBuffers 		= Buffers;

			// 假如真的有任何资料存在,试着将资料解密
			if (cbData)
				ss = DecryptMessage(phContext, &SecBuffDesc, 0, NULL);

			if (ss == SEC_E_INCOMPLETE_MESSAGE || cbData == 0){
				ULONG lReadSize;
				// 必须读进更多资料并且再试一次
				lReadSize = lSizeExtraDataBuf - cbData;
				ReceiveData(pbData+cbData, &lReadSize);
				cbData += lReadSize;
			}

		}while (ss == SEC_E_INCOMPLETE_MESSAGE);

		// 解密成功了吗?
		if (ss == SEC_E_OK){

			// 分配缓冲器给呼叫者并且复制解密后的讯息给它
			*plSize = Buffers[1].cbBuffer;
			pDecrypMsg = (PVOID)LocalAlloc(0, *plSize);
			if (pDecrypMsg == NULL){
				__leave;
			}
			CopyMemory(pDecrypMsg,Buffers[1].pvBuffer,*plSize);

			// 假如有附加资料的话,把它移到缓冲器的开始处,然后再设定附
			// 加资料传回的大小值
			int nIndex = 3;
			while(Buffers[nIndex].BufferType
				!= SECBUFFER_EXTRA && (nIndex-- != 0));

			if (nIndex != -1){
				// 有更多资料要处理。把它移到附加资料缓冲器的前端,
				// 呼叫者可以处理解密后的讯息然后再返回完成其馀的动
				//作
				*pcbExtraData = Buffers[nIndex].cbBuffer;
				MoveMemory(pbData, pbData + (cbData - *pcbExtraData),
					*pcbExtraData);
			}
		}
		if (ss == SEC_I_RENEGOTIATE){
			*pfReneg = TRUE;
		}

	}__finally{}
	// 传回解密后的讯息
	return (pDecrypMsg);
}

从这个程序代码中可以看到,这些函数除了必须注意附加资料的可能性以外,其逻辑与本章其他的范例函数类似。反之,传送函数不用担心附加资料的部份,其接收或解密函数必须取得具有潜在附加资料的缓冲器,并能够传回这个相同缓冲器里的附加资料。这个接收动作可以与它之前、之后,或两者的接收动作重叠。

SSL合并了加密及讯息签章的概念,所以不必为SSL实作MakeSignature及VerifySignature函数的部份。Schannel安全性套件不支援这个函数。您可能会注意到GetEncryptedMessage范例函数中测试了SEC_I_RENEGOTIATE的传回值,假如这个值是由DecryptMessage函数传回时,它会填入一个为TRUE值的Boolean变数。此回传值指出服务器要求重新协商凭证,而且通常表示它想把匿名的客户端验证提升为具有凭证的完整客户端验证。

当DecryptMessage传回SEC_I_RENEGOTIATE值时,表示没有解密过的讯息,而且任何输出缓冲器中不会有解密资料存在。

重新协商的处理
 

假如客户端对一个被保护的资源提出请求,此时您的服务器可能会想要把匿名的客户端验证提升为具有凭证的验证。如此一来,您的服务器就必须初始一个重新协商的程序。

通常SSL的通讯中,客户端及服务器会经由传送及接收加密的资料缓冲器而互相通讯。然而,当服务器想要初始一个重新协商的程序而非加密及传送资讯时,服务器便会呼叫AcceptSecurityContext函数。在这种情况下,服务器没有传递输入缓冲器到函数中,而且函数会传回应该传送到客户端的Blob值。客户端会了解此Blob表示它应该开始一个重新协商的程序。以下的范例函数显示服务器实作这个程序代码的方法:

BOOL SSLServerInitReneg(
	CredHandle* phCredentials,
	CtxtHandle* phContext)
{

	BOOL fSuccess = FALSE;
	__try
	{

		// 宣告输出缓冲器
		SecBuffer secBufferOut;
		SecBufferDesc secBufDescriptorOut;

		// 设定输出缓冲器(经由SSPI分配)
		secBufferOut.BufferType = SECBUFFER_TOKEN;
		secBufferOut.cbBuffer = 0;
		secBufferOut.pvBuffer = NULL;

		// 设定输出缓冲器描述项
		secBufDescriptorOut.cBuffers = 1;
		secBufDescriptorOut.pBuffers = &secBufferOut;
		secBufDescriptorOut.ulVersion = SECBUFFER_VERSION;

		ULONG lAttrOut = 0;
		SECURITY_STATUS ss =
			AcceptSecurityContext(
				phCredentials,
				phContext,
				NULL,
				ASC_REQ_ALLOCATE_MEMORY|ASC_REQ_STREAM|ASC_REQ_MUTUAL_AUTH,
				SECURITY_NATIVE_DREP,
				phContext,
				&secBufDescriptorOut,
				&lAttrOut,
				NULL);
		if (ss != SEC_E_OK)
			__leave;
		// 有资料要传送吗?
		if (secBufferOut.cbBuffer!=0){
			// 传送它
			ULONG lOut = secBufferOut.cbBuffer;
			SendData(secBufferOut.pvBuffer, lOut);
			// 然后释放输出缓冲器
			FreeContextBuffer(secBufferOut.pvBuffer );
		}
		fSuccess = TRUE;
	}__finally{}

	return (fSuccess);
}

如同我们在DecryptMessage函数讨论中提到的,当DecryptMessage传回SEC_I_RENEGOTIATE值时,客户端首先会知道服务器要求重新协商的程序。这个时候,假如客户端同意重新协商,则它应该带着适当的凭证handle进入验证回圈的程序中。

服务器也应该在要求重新协商后进入自己的重新协商验证程序。服务器的责任即是检验客户端是否已经提供适当的凭证。客户端很有可能只重新传送它的匿名凭证。

以下列出的程序代码片段表示服务器在重新协商程序中所扮演的角色。

// 服务器需要与客户端重新协商
if(!SSLServerInitReneg(
	&hCredentials,
	&hContext)){
	// 错误情况
}

// 设定验证交握的标记
dwSSPIFlags = ASC_REQ_SEQUENCE_DETECT   |
              ASC_REQ_REPLAY_DETECT     |
              ASC_REQ_CONFIDENTIALITY   |
              /* 指出要求客户端凭证 */
              ASC_REQ_MUTUAL_AUTH       |
              ASC_RET_EXTENDED_ERROR;

cbExtra = 0;
// 再进入验证回圈
ss = SSLServerHandshakeAuth(&hCredentials,
	&dwSSPIFlags, &hContext, pIOBuff, &cbExtra, lIOBuffSize);
if (ss == SEC_E_OK){
	// 重新协商成功
}

同样的,以下列出的程序代码片段显示在重新协商程序中客户端所扮演的角色(假设客户端拥有一个重新协商的凭证):

// 照惯例取得解密讯息
cbMsg = 0;
cbExtra = 0;
pMsg =
	GetEncryptedMessage(
		&hContext,
		&cbMsg,
		&pIOBuff,
		&cbExtra,
		lIOBuffSize,
		&fReneg);
// 假如它执行失败,这是重新协商的请求吗?
if ((pMsg == NULL) && fReneg){
	// 如果是,那么带着与凭证相关的凭证handle重新进入验证回圈
	ULONG   lSSPIFlags = ISC_REQ_SEQUENCE_DETECT |
	                     ISC_REQ_REPLAY_DETECT     |
	                     ISC_REQ_CONFIDENTIALITY   |
	                     ISC_RET_EXTENDED_ERROR;
	ss =
		SSLClientHandshakeAuth(
			&hCertCredentials,
			&hCertCredentials,
			&lSSPIFlags,
			&hContext,
			TEXT("Jason's Test Server Certificate on Davemm"),
			pIOBuff, &cbExtra, lIOBuffSize);
	if (ss == SEC_E_OK){
		// 重新协商成功
	}
}

说明

如果您的客户端软件必须与要求重新协商的服务器通讯时,无论它什么时候会呼叫DecryptMessage函数,能够让您的客户端优雅地处理重新协商要求是很重要的。


SSL及模拟(Impersonation)
 

假如服务器已经透过客户端之凭证而验证客户端(相对于匿名的客户端验证)时,服务器有可能在那个时候模拟客户端。事实上,模拟的细节与其他使用SSPI安全性通讯协定的模拟方式不同。您只需传递完整的环境handle到ImpersonateSecurityContext函数中,或是直接使用QuerySecurityContextToken函数撷取权杖即可(这两种技巧在本章稍早时曾讨论过)。

然而,剩下一个重要的问题:Windows 2000为何只使用凭证即可为使用者建立一个权杖?这个问题的答案是透过凭证对应到Active Directory,或是经由存放在凭证本身的特定Microsoft资讯。有叁种方法,可以把凭证对应到Windows 2000网域的使用者信任帐户权杖。

在讨论可以用来把凭证对应到网域里的使用者方法前,我想要花些时间说明这个对应的重要性。

假如在Windows 2000上执行的服务器连接到具有已知凭证的客户端时,不管客户端使用的操作系统为何,您的服务器可能会建立一个权杖并把这个客户端登录您的服务器机器上。这个方法可以让您模拟连结到客户端的情形,可以在UNIX、Windows,或任何其他具有SSL通讯能力的机器上执行客户端软件。

这里有叁种不同的方法,可让您将凭证对应到Windows 2000使用者帐户中:

  •  一对一对应 任何经由服务器所信任之发行者签章过的凭证皆可以与使用者帐户关联。当SSL连结使用此凭证初始一个模拟时,系统会在Active Directory中查询凭证上的常见名称及发行者名称,以找到应该建立权杖的使用者帐户。
     
  •  多对一对应 任何服务器所信任的CA可以与使用者帐户关联。当这个CA所签署的任何凭证在SSL连结中被用来初始一个模拟时,系统会在Active Directory中查询发行者的名称,以找出应该建立权杖的使用者帐户。
     
  •  使用者主要名称(User principal name,UPN)对应 和其他两个方法不同的是,UPN对应使用凭证内的资讯以找出应该建立权杖的使用者帐户。UPN是应该被模拟之帐户的使用者主要名称,并且会以特殊栏位的方式存放在凭证上。UPN看起来大约像这样:「[email protected]」。
     

尽管SSL连结的模拟方式是简单的,这些凭证对应到的管理工作是您的责任。最简单的方法及要求最少的管理即是UPN对应的方式。使用UPN对应,您只需执行Microsoft凭证服务的CA,以作为企业的CA即可。然后您可以从这个CA中要求一个「使用者」凭证。这个使用者凭证将包括UPN属性,它包含要求凭证的使用者帐户名称。假如这个凭证被使用在客户端到SSL服务器的连结中,它就可以被模拟。

当然,您可能会发现您想要模拟不具有特定Microsoft之延伸部分所产生的凭证,这样做会需要您在Active Directory中作明确的对应。一对一对应及多对一对应都使用类似的技巧,把凭证对应到Active Directory中的使用者。

刚开始时,您必须将凭证汇出到一个 .CER文件中。这个文件类型是标准凭证汇出的格式,它包括凭证的签章副本及公开金钥部份,而不包括与凭证相配的私密金钥。MMC中的凭证嵌入式管理单元可让您在凭证上按下滑鼠右键,然后选择所有工作和汇出选项,以汇出一个凭证。这会启动凭证汇出精灵。一旦您有了 .CER文件,就应该采用这些步骤去建立对应到Active Directory中的使用者帐户:

  1. 开启MMC中的Active Directory使用者及电脑嵌入式管理单元。
  2. 从检视功能表中选择进阶特色选项。
  3. 在左窗格中选择使用者资料夹。
  4. 在您想要将与右边窗格中的凭证相对应的使用者上点选滑鼠右键,然后从内容功能表中选择名称对应选项。
  5. 在安全性识别对应之对话方块中的X509凭证标签页中,点选新增按钮。
  6. 选择您想对应之凭证的 .CER文件,然后点选开启选项。此时会出现新增凭证对话方块。
  7. 假如您核取了使用主体作为其他的安全性识别核取方块时,表示您将使用一对一的对应方式。
  8. 假如您取消核取使用主体作为其他的安全性识别核取方块,而只留下已核取的使用发行者作为其他的安全性识别核取方块时,表示您将使用多对一的对应方式。

在这些步骤被适当地执行后,您的服务器软件可以开始模拟客户端。


说明

使用凭证对应时,有两个常见的情形必须注意。首先,假如服务器并不信任凭证的发行者,与该凭证关联的客户端即不能被模拟。第二,如果凭证是执行Microsoft凭证服务之企业所建立的使用者凭证,在Active Directory中搜寻明确的对应前,会先使用UPN对应方式。假如您已明确地对应这种凭证时,会导致无法预料的结果。


SSL之疑难排解
 

在Windows 2000上实作SSL的Schannel安全性套件支援了错误及追踪报告的机制,并会把事件加入事件日志中。它会预设将错误情形记录到系统日志中。然而,它也可以被设定为将警告等级和追踪等级的事件记录到日志中。

为了赋予这些特色,您必须修改登录中的某个值。这个机码位于HKEY_LOCAL_MACHINE/System/CurrentControlSet/Control/SecurityProviders/Schannel中,而这个值的名称为EventLogging。

EventLogging值是叁个位元值的组合,位元值1表示错误,位元值2表示警告报告,而位元值4则表示追踪事件报告。您可以组合这些值以达到想要的任何报告组合。例如,EventLogging值为3会指示错误及警告报告,反之,EventLogging值为7会打开所有报告等级。这个特色对于了解安全性提供者在执行代表使用SSL客户端及服务器软件的工作时很有帮助。

使用SSPI实作SSL时,必定会牵涉一些比较难的程序代码,但是了解必须做什么再加上一个好的范例会很有帮助。在此强烈地建议您在试图实作自己的SSL服务器前,花些时间更深入地了解本书的范例和《Platform SDK》文件中的范例。

SSLChat范例应用程序
 

SSLChat范例应用程序(「12 SSLChat.exe」)说明如何使用目前为止我们已讨论过的所有SSL相关技术,包括客户端及服务器凭证的验证、讯息签章及加密的部份。范例应用程序的原始码及资源档存放在随书光碟上的12-SSLChat目录中。图12-7为SSLChat范例应用程序执行时的使用者介面。

SSLChat范例应用程序与SSPIChat范例应用程序非常类似。然而,在SSLChat范例应用程序中,客户端及服务器都可以选择一个凭证去验证远端原则。

客户端及服务器机器必须能够识别彼此之凭证的根CA,这是很重要的。完整讨论从使用者观点出发的凭证管理部份超出了本书的范围,您可以在Windows 2000 Help中查询这个主题,以取得一些资讯。


 

 图12-7 SSLChat范例应用程序的使用者介面

安全通讯的应用
 

您应该尽可能提供所取得的资讯,以决定如何保护服务之通讯安全。以下是一些您应该考虑的问题:

  1. 在Windows网域帐户中验证我的客户端是重要的吗?
  2. 我的客户端存在我的网域中、我的网域外(可能经由Internet)或两者皆是?
  3. 我将与非Windows客户端互相操作吗?

假如您的验证需求将会简化Windows的权杖(对于模拟及存取控制是极好的),则您可能会优先考虑使用NTLM或Kerberos。然而,您也可以使用SSL并让系统维护凭证到网域帐户的对应部份。这需要额外的维护工作,如先前所述。

NTLM及Kerberos会向网域验证权杖。所以若您对第一个问题的答案为「是」,则您需要额外的动机来使用SSL及凭证。

如果您的客户端将会存在您的网域中,则您应选择开放的方式。可以使用命名管道,它会自动使用Kerberos或NTLM验证的方式,并且会大大地减轻这个程序的负载。当然,管道也有可调适的议题,假如您须要与无数的客户端连结,此时您应该使用TCP/IP通讯端(Socket)。

若客户端确实位于您的网域中,会使程序代码变得更容易。您可以将您的服务器设定为使用协商安全性套件,您的客户端则可以使用对他们最适当的套件。Windows 2000将会使用Kerberos,而其他则可能使用NTLM。使用这些选择,可以让您设计一个乾净且相对容易实作的安全环境。

假如您的客户端位于您的网域外会发生什么事?若与取得Windows权杖无关,在您网域外的客户端可能是使用SSL及凭证。如果在服务器端不需要使用者的权杖时,表示您有一些与模拟相关的议题需要处理。

假如客户端位于您的网域外,您可以假定客户端能够与您的服务器通讯。这表示您可以考虑使用NTLM通讯协定。客户端将会传递它的使用者名称、密码及服务器的网域到AcquireCredentialsHandle函数中。这个资讯会被传递到服务器。由于客户端只与服务器通讯,所以客户端没有必要存取DC的内容,此时客户端可以位于网域外。同时,服务器位于您的网域内,会存取到DC的内容,并且可以取得客户端的权杖。这是非常好的。

在使用Kerberos通讯协定时,处理位于网域外的客户端就没有这么简单了。许多验证程序会牵涉到与KDC通讯的客户端。就效能来说这是很好的,但是与您网域外的客户端通讯可能会变得复杂。所以一旦决定再次验证时,可以考虑使用NTLM通讯协定,因为位于网域外的使用方式会比较简单。

SSL可被用来取得您服务器端的权杖,它要求客户端只与服务器通讯,但是它存在着凭证管理的包袱。您会在哪里取得您的凭证?您会付给他们或是由自己发行?假如由您自行发行,则取得客户端的信任会很困难吗?假如不会,您和您的客户端信任相同的根CA吗?最后,若权杖是您的目标,您必须管理对应到Active Directory的凭证,或使用执行Microsoft凭证服务之企业CA所发行的凭证(在其他方面可能不符合您的需要)。有关与其他操作系统沟通的议题呢?SSL在这方面很杰出。SSL及凭证之间调适得很好,并且可在其他操作系统上实作。我们的实作就沟通得很好!但是SSL也带来了一些您所知的限制。

您还有一些其他的选择。Kerberos SSP就可以与UNIX之一般安全性服务(Generic Security Service,GSS)程序库沟通。在使用EncryptMessage及DecryptMessage函数时,您必须处理一些额外的细节部份。无论如何,这个沟通能力使得您能够让UNIX客户端使用Kerberos通讯协定,并与您的Windows服务器通讯。GSS及其沟通的部份在《Platform SDK》文件中有详细的讨论。《Platform SDK》中也包括完整的GSS范例。

加密不等于具有安全性
 

一旦您已经掌握SSPI及其他Windows所提供的特色后,可能就会认为您的服务器已经是安全的了,然而这并不一定真是如此。

Kerberos及NTLM使安全性变得更容易,因为他们使用以使用者密码为基础的对称性加密方式。一旦您的凭证通过验证后,即可以适度地确信您正在与知道密码的实体通讯,尤其当您在使用Kerberos时(然而,密码并不具有「强大的」安全性,因为它们比私密金钥更容易被「知道」或取得。所以密码的安全性是首要关切的部分)。

SSL和NTLM及Kerberos一样不简单。凭证并不是原来就被已对应到网域中的使用者帐户,他们也不直接对应到与密码一样简单的已知机密上。所以这是您必须不断考虑的问题:我正在与这个凭证私密金钥的持有者做安全地通讯,如何才能得知谁是持有者?

公开金钥加密及解密给予我们强大的工具,用来确保我们正与某个实体通讯,并且保证只有一个实体。然而是否应该信任第一个实体的问题仍然未定。这是一个隐含的困难点:所有凭证中的资讯皆是公开的。所以您必须检查什么资讯以确保考虑中的凭证属于您想要连结的实体?

  • 您信赖凭证中的常见名称吗?某人可以使用另一个CA建立一个与常见名称相同的凭证吗?
     
  • 您信赖和URL相符的常见名称吗?某人有可能会建立另一个与相同URL相称的凭证吗?
     

所有这些问题的答案皆为「是」,但是了解这些资讯并不会使您的验证程序更安全。所以对您来说,了解与发行凭证之CA相关的附加资讯才是必要的:

  • CA保证什么资讯是唯一的?
     
  • 在获得凭证之前,CA做了多少调查的动作?
     

另一个要考虑的事情是您应该如何传递这些问题给使用者。您如何以不令人感到困惑并且不会导致使用者放弃的方式而使其信任每件事及每个人?

这些问题所造成的挑战是严重的,尽管没有出现单一的解决办法为每个方案解决这些问题,但是它可以被克服。适当的加以考虑后,您便可以解决这些问题,并使其符合您的特殊需求。总之,公开金钥基础建设是很强大的,假如您选择使用它,表示您可以使用安全的方法实行它。您只须回答我刚刚为您的软件所提出的所有问题。指出这些问题的答案是安全性程序设计的挑战,而其原因有助于安全性软件的编写。

 图12-3 及 12-4 中,说明客户端及服务器端的SSPI会谈内容是有帮助的。请在我们刚刚检查的程序代码片段环境中思考这些图。

 图12-3 服务器观点的安全会谈,有编号的方块为呼叫服务器与客户端通讯的地方,它们被编号以使您配合图12-4所显示的客户端流程图之通讯点

图12-3中说明的会谈显示了使用者环境的建立、使用者环境如何模拟客户端,和传送及接收加密的讯息。当然,实际的会谈中可能有更多讯息,而且真正的服务器会基于这些讯息而执行动作。


说明

不是所有使用SSPI的服务器皆会对每个与客户端的交易做加密。资料的加密可能并非必要的,所以服务器可能会以讯息的签章做为替代。这种类型的细节-是否需要加密-是您作为服务开发人员所要做的决定。您完全不必对您的资料做签章或加密的动作。假如没有的话,您只需使用SSPI作初始验证。


客户端及SSPI
 

除了客户端不能作模拟之外,客户端使用SSPI来达到与服务器相同的目标。跟服务器一样,客户端会呼叫某些函数来达成这些目标。这些函数列于表12-3中。

 表12-3 客户端的SSPI函数
工作 函数 说明
初始验证/工作阶段 AcquireCredentialsHandle 用来撷取象徵凭证的初始化handle。一 个或多个客户端环境可以与一个凭证 handle关联,同时选择您所使用的安全性 通讯协定。
InitializeSecurityContext 重覆地呼叫。从这个函数传回的Blobs会 被传送到服务器的AcceptSecurityContext 函数中,反之亦然,直到环境已经完整 为止。
资料私密性/加密 EncryptMessage DecryptMessage 传递加密资料及建立Blobs。 取得由EncryptMessage所建立的Blobs, 并且传回解密资料。
资料完整性及讯息签章 MakeSignature 签章应用程序所提供的资料及建立通 讯用的Blobs。
VerifySignature 取得从MakeSignature传回的Blobs及检 查包含在Blobs里的讯息签章。
清除 DeleteSecurityContext FreeCredentialsHandle 当您用完它们时,用来清除handles。

请注意,服务器函数(在表12-2中叙述)及客户端函数间主要的不同处是客户端会重覆地呼叫InitializeSecurityContext,而不是AcceptSecurityContext。图12-4从客户端的观点说明图12-3中说明的会谈内容。

第十一章 的内容)。 www.Verisign.com 找到,以及Thawte,可以在 www.Thawte.com 找到。还有更多相关的实例。

你可能感兴趣的:(服务器,windows,程序开发,通讯,加密,security)