更新:有更好的方法,请参阅 cmets。
您可以捕获证书并使用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 中的证书验证
verify host在不同软件中的意思好像murky at best.
所以我决定深入了解,并下载了 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 无法知道,在“context-option-setting”上下文中,将使用哪些选项。
同样,证书解析代码解析证书并提取 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 也使用verify_peer 在CN 上运行一些 检查,但单独留下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,因此可以自行检查,检测假冒。