如何验证服务器主机名

5

我正在使用XE2附带的Indy TIdHTTP 和 OpenSSL库DLLs V1.0.1m,在HTTPS连接时验证证书。我已经为TIdSSLIOHandlerSocketOpenSSL组件实现了OnVerifyPeer事件处理程序。

function TForm1.IdSSLIOHandlerSocketOpenSSL1VerifyPeer(Certificate: TIdX509;
  AOk: Boolean; ADepth, AError: Integer): Boolean;
begin
  (...)
end;

根据RFC 2818第3.1章,如果客户端可以获取到主机名,则客户端必须检查它与服务器证书消息中呈现的服务器标识相匹配,以防止中间人攻击。
现在我有一个问题需要验证服务器证书的主机名:
尽管服务器证书的Subject字段中存在通配符(*.google.com),但是OnVerifyPeer事件的Certificate.Subject.OneLine参数返回的CN没有任何通配符(即google.com而不是*.google.com)。
根据RFC 2818第3.1章所述,通配符字符“*”用于匹配任何单个域名组件或组件片段。
1.有人能否确认Indy或OpenSSL库是否会删除通配符字符,即使需要验证主机名?
2.在这种情况下,有人有验证主机名的想法吗?
非常感谢您的帮助。谢谢阅读。

证书数据由OpenSSL本身提供。Indy的TIdX509类只是在Indy的验证回调函数中包装了一个由OpenSSL提供的PX509句柄。TIdX509不会对证书数据进行任何处理,它会按原样呈现。Subject属性包装了来自OpenSSL的X509_get_subject_name()函数的PX509_NAME句柄,而OneLine属性返回OpenSSL的X509_NAME_oneline()函数返回的任何值。因此,是OpenSSL本身剥离了通配符。 - Remy Lebeau
1
话虽如此,OpenSSL确实有X509_check_host()certificate_host_name_override()函数。您可以将原始的PX509句柄(即TIdX509.FX509成员 - 您需要使用访问器类才能到达它)和您连接到的主机名传递给它们。 - Remy Lebeau
谢谢Remy的快速回复。我明天会尝试并告诉您我的结果。 - Cheesy
现在我有一些时间来处理这个问题,但是你所说的“accessor class”具体是什么意思呢? 在我看来,派生类没有用处,因为我无法强制Indy访问它。 - Cheesy
你可以通过将目标类的对象指针强制转换为派生类来访问其“protected”成员。派生类不会引入任何新的数据成员,因此具有与目标类相同的大小和布局。例如,在这种情况下:type TIdX509Access = class(TIdX509) end; var HostName: AnsiString; HostName := ...; if x509_check_host(TIdX509Access(Certificate).FX509, PAnsiChar(HostName), Length(HostName), 0, nil) = 1 then ... - Remy Lebeau
显示剩余2条评论
2个回答

3

有人能确认Indy或OpenSSL库是否删除通配符字符,尽管验证主机名是必要的吗?

不,OpenSSL不会删除它。

我不知道Indy库。


请问有人能确认Indy或OpenSSL库是否删除了通配符字符,尽管验证主机名是必要的吗?
我引用了两次,是有原因的 :) 在“通用名称”(CN)中放置服务器名称已被IETF和CA/B论坛(浏览器遵循的)淘汰。
您可能正在经历的是类似于CN=example.com的情况。在这种情况下,example.com不是服务器名称;而是一个域名。因此,您不应该假设它的意思是匹配*.example.com
如果服务器在https://example.com上回答,只有当“主题备用名称”包括example.com时,您才应该接受证书,因为公共CA会在CN中列出DNS名称。公共CA将DNS名称放在SAN中,因为它们遵循CA/B论坛。

有人知道如何在这些情况下验证主机名吗?

OpenSSL 1.1.0之前没有执行主机名匹配,开发人员必须自己完成。OpenSSL 1.1.0及以上版本已经内置了此功能。请参见X509_check_host(3)和相关文档。

为了匹配主机名,您应该从公共名称(CN)主题备用名称(SAN)中收集所有名称。然后,通常只需进行正则表达式匹配即可。

IETF的标准比较宽松,允许主机名出现在CN或SAN中。CA/B论坛和浏览器更加严格:如果主机名出现在CN中,则它也必须出现在SAN中(是的,必须列两次)。否则,CA/B论坛和浏览器期望在SAN中列出所有主机名。

我认为OpenSSL和CA/B论坛只允许在最左侧的标签中使用通配符。而IETF则允许通配符出现在任何位置。

如果你想查看示例代码,那么请查看cURL的实现。cURL使用OpenSSL,但不依赖于1.1.0的X509_check_host(3)和相关内容。cURL有自己的实现。

快速警告。主机名匹配是一门黑艺术。例如....

IETF允许匹配全球顶级域名(gTLD),如*.com*.net;和国家顶级域名(ccTLD),如*.uk*.us。我认为这是一种攻击,因为我知道没有一个CA可以声称“拥有”或“认证”gTLD。如果我在野外遇到其中之一的证书,那么我会拒绝它。

CA / B论坛不允许通配符gTLD或ccTLD。浏览器试图通过使用Public Suffix List(PSL)来避免它。随着虚荣域名的出现,情况只会变得更糟,例如*.google

浏览器还尝试使用PSL在子域上划分管理边界。例如,亚马逊拥有所有amazon.com,但他们将权限委托给子域,如example.amazon.com。因此,PSL试图允许亚马逊控制其域amazon.com,但不能控制您的商家相关子域example.amazon.com

IETF 正试图在 DBOUND 工作组 中解决行政边界问题,但委员会似乎陷入了僵局。


有人能否确认Indy或OpenSSL库是否删除了通配符字符,尽管验证主机名是必要的?虽然我非常确定通配符在“Certificate.Subject.OnLine”中被剥离,但这种现象再次发生了(在https://www.youtube.com上进行测试)。 - Cheesy

1
很遗憾,由于内部规格要求,我必须坚持使用XE2-Indy和OpenSSL V1.0.1m。
为了验证主机名与Subject CN和Subject Alternate Names是否匹配,我已经采取了以下措施(使用cURL实现的方法):
1. 在应用程序启动时,我尝试一次性扩展Indy加密库中的方法访问权限。
function ExtendIndyCryptoLibrary(): Boolean;
var
  hIdCrypto: HMODULE;
begin
  Result := False;

  // Try to get handle to Indy used crypto library
  if not IdSSLOpenSSL.LoadOpenSSLLibrary() then
    Exit;
  hIdCrypto := IdSSLOpenSSLHeaders.GetCryptLibHandle();
  if hIdCrypto = 0 then
    Exit();

  // Try to get exported methods that are needed additionally
  @X509_get_ext_d2i := GetProcAddress(hIdCrypto, 'X509_get_ext_d2i');

  Result := Assigned(X509_get_ext_d2i);
end;

2. 以下类帮助我访问和验证SAN和CN。

type
  THostnameValidationResult = (hvrMatchNotFound, hvrNoSANPresent, hvrMatchFound);
var
  X509_get_ext_d2i: function(a: PX509; nid: TIdC_INT; var pcrit: PIdC_INT; var pidx: PIdC_INT): PSTACK_OF_GENERAL_NAME; cdecl = nil;
type
  TIdX509Access = class(TIdX509)
  protected
    function Hostmatch(Hostname, Pattern: String): Boolean;
    function MatchesSAN(Hostname: String): THostnameValidationResult;
    function MatchesCN(Certificate: TIdX509; Hostname: String): THostnameValidationResult;
  public
    function ValidateHostname(Certificate: TIdX509; Hostname: String): THostnameValidationResult;
  end;

implementation

{ TIdX509Access }

function TIdX509Access.Hostmatch(Hostname, Pattern: String): Boolean;
begin
  // Match hostname against pattern using RFC, CA/Browser Forum, ...
  // (...)
end;

function TIdX509Access.MatchesSAN(Hostname: String): THostnameValidationResult;
var
  pcrit, pidx: PIdC_INT;
  psan_names: PSTACK_OF_GENERAL_NAME;
  san_names_nb: Integer;
  pcurrent_name: PGENERAL_NAME;
  i: Integer;
  DnsName: String;
begin
  Result := hvrMatchNotFound;

  // Try to extract the names within the SAN extension from the certificate
  pcrit := nil;
  pidx := nil;
  psan_names := X509_get_ext_d2i(FX509, NID_subject_alt_name, pcrit, pidx);
  // Check if SAN is present
  if psan_names <> nil then
  begin
    san_names_nb := sk_num(PSTACK(psan_names));
    // Check each name within the extension
    for i := 0 to san_names_nb-1 do
    begin
      pcurrent_name := PGENERAL_NAME( sk_value(PSTACK(psan_names), i) );
      if pcurrent_name._type = GEN_DNS then
      begin
        // Current name is a DNS name, let's check it
        DnsName := String(pcurrent_name.d.dNSName.data);
        // Compare expected hostname with the DNS name
        if Hostmatch(Hostname, DnsName) then
        begin
          Result := hvrMatchFound;
          Break;
        end;
      end;
    end;
  end
  else
    Result := hvrNoSANPresent;
  // Clean up
  sk_free(PSTACK(psan_names));
end;

function TIdX509Access.MatchesCN(Certificate: TIdX509;
  Hostname: String): THostnameValidationResult;
var
  TempList: TStringList;
  Cn: String;
begin
  Result := hvrMatchNotFound;

  // Extract CN from Subject
  TempList := TStringList.Create();
  TempList.Delimiter := '/';
  TempList.DelimitedText := Certificate.Subject.OneLine;
  Cn := Trim(TempList.Values['CN']);
  FreeAndNil(TempList);

  // Compare expected hostname with the CN
  if Hostmatch(Hostname, Cn) then
    Result := hvrMatchFound;
end;

function TIdX509Access.ValidateHostname(Certificate: TIdX509;
  Hostname: String): THostnameValidationResult;
begin
  // First try the Subject Alternative Names extension
  Result := MatchesSAN(Hostname);
  if Result = hvrNoSANPresent then
  begin
    // Extension was not found: try the Common Name
    Result := MatchesCN(Certificate, Hostname);
  end;
end;

3. 在TIdSSLIOHandlerSocketOpenSSL组件的OnVerifyPeer事件中,可以按以下方式使用该类:

function TForm1.IdSSLIOHandlerSocketOpenSSL1VerifyPeer(Certificate: TIdX509;
  AOk: Boolean; ADepth, AError: Integer): Boolean;
begin
  // (...)
    Result := TIdX509Access(Certificate).ValidateHostname(Certificate, IdHttp1.URL.Host) = hvrMatchFound;
  // (...)
end;

@RemyLebeau:我使用这种方法会不会在Indy内部搞砸呢? 到目前为止,我还没有遇到任何问题。我只是想防止其他人使用这个实现方式时出错。 - Cheesy
谢谢您的提示。虽然我已经接受了答案,但我仍然希望能收到Remy的回复。 - Cheesy
注意在 CN 中的域名是非常常见的。也就是说,你会遇到 CN=example.com。在这种情况下,example.com 是一个域名,而不是一个服务器名称。你不应该将其解释为主机 example.com 或者所有在域 *.example.com 中的主机。服务器名称应该放在 Subject Alternate Name (SAN) 中。 - jww

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接