更新:有更好的方法,请参阅评论。
您可以捕获证书并使用openssl
作为过滤器与服务器进行对话。通过这种方式,您可以提取证书并在同一连接期间对其进行检查。
这是一个不完整的实现(不存在实际的邮件发送对话),应该可以帮助您入门:
<?php
$server = 'smtp.gmail.com';
$pid = proc_open("openssl s_client -connect $server:25 -starttls smtp",
array(
0 => array('pipe', 'r'),
1 => array('pipe', 'w'),
2 => array('pipe', 'r'),
),
$pipes,
'/tmp',
array()
);
list($smtpout, $smtpin, $smtperr) = $pipes; unset($pipes);
$stage = 0;
$cert = 0;
$certificate = '';
while(($stage < 5) && (!feof($smtpin)))
{
$line = fgets($smtpin, 1024);
switch(trim($line))
{
case '-----BEGIN CERTIFICATE-----':
$cert = 1;
break;
case '-----END CERTIFICATE-----':
$certificate .= $line;
$cert = 0;
break;
case '---':
$stage++;
}
if ($cert)
$certificate .= $line;
}
fwrite($smtpout,"HELO mail.example.me\r\n"); // .me is client, .com is server
print fgets($smtpin, 512);
fwrite($smtpout,"QUIT\r\n");
print fgets($smtpin, 512);
fclose($smtpin);
fclose($smtpout);
fclose($smtperr);
proc_close($pid);
print $certificate;
$par = openssl_x509_parse($certificate);
?>
当然,在向服务器发送任何有意义的内容之前,您将移动证书解析和检查。
在$par
数组中,您应该找到(在其余部分中)名称,与主题相同。
Array
(
[name] => /C=US/ST=California/L=Mountain View/O=Google Inc/CN=smtp.gmail.com
[subject] => Array
(
[C] => US
[ST] => California
[L] => Mountain View
[O] => Google Inc
[CN] => smtp.gmail.com
)
[hash] => 11e1af25
[issuer] => Array
(
[C] => US
[O] => Google Inc
[CN] => Google Internet Authority
)
[version] => 2
[serialNumber] => 280777854109761182656680
[validFrom] => 120912115750Z
[validTo] => 130607194327Z
[validFrom_time_t] => 1347451070
[validTo_time_t] => 1370634207
...
[extensions] => Array
(
...
[subjectAltName] => DNS:smtp.gmail.com
)
要检查有效性,除了 SSL 自行执行的日期检查等之外,您必须验证这些条件中的任何一个是否适用:
实体的 CN 是您的 DNS 名称,例如“CN = smtp.your.server.com”
定义了扩展,它们包含一个 subjectAltName,一旦使用 展开explode(',', $subjectAltName)
,就会产生一组以 - 为前缀的DNS:
记录,其中至少有一个与您的 DNS 名称匹配。如果不匹配,则拒绝证书。
PHP中的证书验证
不同软件中验证主机的含义充其量似乎是模糊的。
所以我决定深入了解这一点,并下载了 OpenSSL 的源代码(openssl-1.0.1c)并尝试自己检查一下。
我没有发现对我期望的代码的引用,即:
- 尝试解析冒号分隔的字符串
- 引用
subjectAltName
(OpenSSL 调用SN_subject_alt_name
)
- 使用“DNS[:]”作为分隔符
OpenSSL 似乎将所有证书详细信息放入一个结构中,对其中一些运行非常基本的测试,但大多数“人类可读”字段都被单独留下。这是有道理的:可以说名称检查比证书签名检查处于更高级别
然后我还下载了最新的 cURL 和最新的 PHP tarball。
在 PHP 源代码中我也一无所获;显然,任何选项都只是向下传递,否则会被忽略。此代码运行时没有警告:
stream_context_set_option($smtp, 'ssl', 'I-want-a-banana', True);
后来stream_context_get_options
尽职尽责地取回
[ssl] => Array
(
[I-want-a-banana] => 1
...
这也是有道理的:PHP 无法知道,在“上下文选项设置”上下文中,将使用哪些选项。
同样,证书解析代码解析证书并提取 OpenSSL 放在那里的信息,但它不会验证相同的信息。
于是又挖得更深了,终于在cURL中找到了一个证书验证码,这里:
// curl-7.28.0/lib/ssluse.c
static CURLcode verifyhost(struct connectdata *conn,
X509 *server_cert)
{
它在哪里执行我的预期:它查找 subjectAltNames,检查所有这些名称的完整性并将它们运行过去hostmatch
,运行 hello.example.com == *.example.com 之类的检查。还有额外的健全性检查:“我们要求模式中至少有 2 个点,以避免通配符匹配过宽。” 和 xn-- 检查。
总而言之,OpenSSL 运行一些简单的检查并将其余的留给调用者。cURL,调用 OpenSSL,实现了更多的检查。PHP 也使用 对 CN 进行了一些检查verify_peer
,但不理会subjectAltName
。这些检查并不能说服我太多;请参阅下面的“测试”。
由于缺乏访问 cURL 函数的能力,最好的选择是在 PHP中重新实现这些函数。
例如,可变通配符域匹配可以通过点爆炸实际域和证书域来完成,颠倒两个数组
com.example.site.my
com.example.*
并验证相应项目是否相等,或证书一为*;如果发生这种情况,我们必须已经检查了至少两个组件,这里com
和example
。
如果您想一次性检查所有证书,我相信上述解决方案是最好的解决方案之一。更好的是能够直接打开流而不求助于openssl
客户端——这是可能的;见评论。
测试
我有一份来自 Thawte 的良好、有效且完全受信任的证书,发给“mail.eve.com”。
然后,在 Alice 上运行的上述代码将与 安全连接mail.eve.com
,并且正如预期的那样。
现在我在 上安装相同的证书mail.bob.com
,或者以其他方式让 DNS 相信我的服务器是 Bob,而实际上它仍然是 Eve。
我希望 SSL 连接仍然有效(证书有效且受信任),但证书不是颁发给 Bob - 它是颁发给 Eve。因此,必须有人进行最后一次检查并警告 Alice Bob 实际上是由 Eve 冒充的(或者等效地,Bob 正在使用 Eve 被盗的证书)。
我使用了下面的代码:
$smtp = fsockopen( "tcp://mail.bob.com", 25, $errno, $errstr );
fread( $smtp, 512 );
fwrite($smtp,"HELO alice\r\n");
fread($smtp, 512);
fwrite($smtp,"STARTTLS\r\n");
fread($smtp, 512);
stream_set_blocking($smtp, true);
stream_context_set_option($smtp, 'ssl', 'verify_host', true);
stream_context_set_option($smtp, 'ssl', 'verify_peer', true);
stream_context_set_option($smtp, 'ssl', 'allow_self_signed', false);
stream_context_set_option($smtp, 'ssl', 'cafile', '/etc/ssl/cacert.pem');
$secure = stream_socket_enable_crypto($smtp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
stream_set_blocking($smtp, false);
print_r(stream_context_get_options($smtp));
if( ! $secure)
die("failed to connect securely\n");
print "Success!\n";
和:
- 如果证书无法通过受信任的机构验证:
- verify_host 什么都不做
- verify_peer TRUE 导致错误
- verify_peer FALSE 允许连接
- allow_self_signed 什么都不做
- 如果证书过期:
- 如果证书是可验证的:
- 允许连接到冒充“mail.bob.com”的“mail.eve.com”,我得到“成功!” 信息。
我认为这意味着,除非我犯了一些愚蠢的错误,否则 PHP 本身不会根据名称检查证书。
使用本文proc_open
开头的代码,我再次可以连接,但这次我可以访问,subjectAltName
因此可以自己检查,检测模拟。