如何验证TLS SMTP证书是有效的PHP?(How do I verify a TLS SMTP

2019-07-03 20:47发布

为了防止人在这方面的中间人攻击(服务器假装是别人),我想验证我也通过SSL进行连接的SMTP服务器具有这证明他是谁,我认为这是一个有效的SSL证书。

例如,在连接到SMTP服务器端口25后,我可以切换到像这样的安全连接:

<?php

$smtp = fsockopen( "tcp://mail.example.com", 25, $errno, $errstr ); 
fread( $smtp, 512 ); 

fwrite($smtp,"HELO mail.example.me\r\n"); // .me is client, .com is server
fread($smtp, 512); 

fwrite($smtp,"STARTTLS\r\n");
fread($smtp, 512); 

stream_socket_enable_crypto( $smtp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT ); 

fwrite($smtp,"HELO mail.example.me\r\n");

然而,没有任何PHP在哪里检查对SSL证书的记载。 请问PHP有根CA内置列表? 难道仅仅是接受什么?

什么是验证证书是有效的,且该SMTP服务器真的是谁,我认为这是正确的方法是什么?

更新

基于对PHP.net此评论似乎我可以使用一些流选项做SSL检查。 最好的部分是, stream_context_set_option接受上下文或流资源 。 因此,在你的TCP连接某些时候,你可以使用切换到SSL CA证书捆绑 。

$resource = fsockopen( "tcp://mail.example.com", 25, $errno, $errstr ); 

...

stream_set_blocking($resource, true);

stream_context_set_option($resource, 'ssl', 'verify_host', true);
stream_context_set_option($resource, 'ssl', 'verify_peer', true);
stream_context_set_option($resource, 'ssl', 'allow_self_signed', false);

stream_context_set_option($resource, 'ssl', 'cafile', __DIR__ . '/cacert.pem');

$secure = stream_socket_enable_crypto($resource, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
stream_set_blocking($resource, false);

if( ! $secure)
{
    die("failed to connect securely\n");
}

此外,看到上下文选项和参数,它扩展了SSL选项 。

然而,尽管这现在解决的主要问题-我如何验证有效证书实际上属于域/ IP我连接到?

换句话说,该证书我连接也可能有一个有效的证书服务器 - 但是我怎么知道它是有效的“example.com”,而不是使用有效的证书充当类似于“example.com”另一台服务器?

更新2

看来你可以捕捉到SSL证书使用蒸汽上下文参数,并解析它openssl_x509_parse 。

$cont = stream_context_get_params($r);
print_r(openssl_x509_parse($cont["options"]["ssl"]["peer_certificate"]));

Answer 1:

为了不加载已经过长,而不再过多的话题,答案与更多的文字,我离开一个对付的原因和因此的,在这里我将介绍如何

我测试了对谷歌和其他几个服务器的代码; 什么评论有有,好,代码中的注释。

<?php
    $server   = "smtp.gmail.com";        // Who I connect to
    $myself   = "my_server.example.com"; // Who I am
    $cabundle = '/etc/ssl/cacert.pem';   // Where my root certificates are

    // Verify server. There's not much we can do, if we suppose that an attacker
    // has taken control of the DNS. The most we can hope for is that there will
    // be discrepancies between the expected responses to the following code and
    // the answers from the subverted DNS server.

    // To detect these discrepancies though, implies we knew the proper response
    // and saved it in the code. At that point we might as well save the IP, and
    // decouple from the DNS altogether.

    $match1   = false;
    $addrs    = gethostbynamel($server);
    foreach($addrs as $addr)
    {
        $name = gethostbyaddr($addr);
        if ($name == $server)
        {
            $match1 = true;
            break;
        }
    }
    // Here we must decide what to do if $match1 is false.
    // Which may happen often and for legitimate reasons.
    print "Test 1: " . ($match1 ? "PASSED" : "FAILED") . "\n";

    $match2   = false;
    $domain   = explode('.', $server);
    array_shift($domain);
    $domain = implode('.', $domain);
    getmxrr($domain, $mxhosts);
    foreach($mxhosts as $mxhost)
    {
        $tests = gethostbynamel($mxhost);
        if (0 != count(array_intersect($addrs, $tests)))
        {
            // One of the instances of $server is a MX for its domain
            $match2 = true;
            break;
        }
    }
    // Again here we must decide what to do if $match2 is false.
    // Most small ISP pass test 2; very large ISPs and Google fail.
    print "Test 2: " . ($match2 ? "PASSED" : "FAILED") . "\n";
    // On the other hand, if you have a PASS on a server you use,
    // it's unlikely to become a FAIL anytime soon.

    // End of maybe-they-help-maybe-they-don't checks.

    // Establish the connection on SMTP port 25
    $smtp = fsockopen( "tcp://{$server}", 25, $errno, $errstr );
    fread( $smtp, 512 );

    // Here you can check the usual banner from $server (or in general,
    // check whether it contains $server's domain name, or whether the
    // domain it advertises has $server among its MX's.
    // But yet again, Google fails both these tests.

    fwrite($smtp,"HELO {$myself}\r\n");
    fread($smtp, 512);

    // Switch to TLS
    fwrite($smtp,"STARTTLS\r\n");
    fread($smtp, 512);
    stream_set_blocking($smtp, 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', 'capture_peer_cert', true);
    stream_context_set_option($smtp, 'ssl', 'cafile', $cabundle);
    $secure = stream_socket_enable_crypto($smtp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
    stream_set_blocking($smtp, false);
    $opts = stream_context_get_options($smtp);
    if (!isset($opts['ssl']['peer_certificate'])) {
        $secure = false;
    } else {
        $cert = openssl_x509_parse($opts['ssl']['peer_certificate']);
        $names = '';
        if ('' != $cert) {
            if (isset($cert['extensions'])) {
                $names = $cert['extensions']['subjectAltName'];
            } elseif (isset($cert['subject'])) {
                if (isset($cert['subject']['CN'])) {
                    $names = 'DNS:' . $cert['subject']['CN'];
                } else {
                    $secure = false; // No exts, subject without CN
                }
            } else {
                $secure = false; // No exts, no subject
            }
        }
        $checks = explode(',', $names);

        // At least one $check must match $server
        $tmp    = explode('.', $server);
        $fles   = array_reverse($tmp);
        $okay   = false;
        foreach($checks as $check) {
            $tmp = explode(':', $check);
            if ('DNS' != $tmp[0])    continue;  // candidates must start with DNS:
            if (!isset($tmp[1]))     continue;  // and have something afterwards
            $tmp  = explode('.', $tmp[1]);
            if (count($tmp) < 3)     continue;  // "*.com" is not a valid match
            $cand = array_reverse($tmp);
            $okay = true;
            foreach($cand as $i => $item) {
                if (!isset($fles[$i])) {
                    // We connected to www.example.com and certificate is for *.www.example.com -- bad.
                    $okay = false;
                    break;
                }
                if ($fles[$i] == $item) {
                    continue;
                }
                if ($item == '*') {
                    break;
                }
            }
            if ($okay) {
                break;
            }
        }
        if (!$okay) {
            $secure = false; // No hosts matched our server.
        }
    }

    if (!$secure) {
            die("failed to connect securely\n");
    }
    print "Success!\n";
    // Continue with connection...


Answer 2:

更新 :有这样做的更好的方法,请参见注释。

您可以捕获该证书,并与使用服务器会话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: -prefixed记录, 其中至少有一个相匹配的DNS名称。 如果没有比赛,该证书将被拒绝。

在PHP证书验证

在不同的软件验证主机的含义似乎阴暗的最好 。

所以我决定在这条底线,并下载OpenSSL的源代码(的OpenSSL 1.0.1c),并试图检查出自己。

我发现我期待,即代码中没有引用:

  • 尝试解析冒号分隔的字符串
  • 以引用subjectAltName (OpenSSL将调用SN_subject_alt_name
  • 使用的“DNS [:]”作为分隔符

OpenSSL的,似乎把所有证书的详细信息在一个结构,运行一些非常基本的测试,但大多数“人类可读”字段独处。 这是有道理的:它可以说是名检查是在比证书签名检查的更高水平

然后,我还下载了最新的卷曲和最新的PHP压缩包。

在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-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的运行一些简单的检查,并留下其余的给调用者。 卷曲,调用OpenSSL的,实现了更多的检查。 PHP也运行与CN 一些检查verify_peer ,但留下subjectAltName孤独。 这些检查没有说服我太多; 参见下面的“测试”。

由于缺乏进入卷曲的功能的能力,最好的办法是重新实现那些在PHP。

可变通配符域例如匹配可以通过点爆炸既实际域名证书域来完成,扭转了两个数组

com.example.site.my
com.example.*

并确认相应的项目或者是相等的,或者证书一个是*; 如果出现这种情况,我们必须已经至少检查两个组件,这里comexample

我认为,上述方案是最好的之一,如果你想查询证书的所有一气呵成。 更妙的是能够打开流的情况下直接诉诸openssl客户端- 这是可能的 ; 看评论。

测试

我从Thawte的一个良好的,有效的,并且完全信任的证书颁发给“mail.eve.com”。

在爱丽丝运行上面的代码会然后安全地连接mail.eve.com ,它也如预期。

现在我上安装相同的证书mail.bob.com ,或者以其他方式,我相信,我的服务器是鲍勃的DNS,而它实际上仍是除夕。

我希望SSL连接仍然有效(证书有效和可信的),但该证书不颁发给鲍勃-它发放给夏娃。 于是有人做出这一个最后检查,并警告爱丽丝鲍勃实际上是被除夕假冒(或等价地,鲍勃是采用夏娃偷来的证书)。

我用下面的代码:

    $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.eve.com”冒充“mail.bob.com”我得到一个“成功了!” 信息。

我认为这意味着的是,对我而言除了一些愚蠢的错误,PHP本身并不核对名称的证书

使用proc_open代码在文章的开头,我又可以连接,但这次我有机会获得subjectAltName ,因此可以自己检查,检测的模拟。



Answer 3:

我怎么验证有效证书实际上属于域/ IP我连接到?

证书颁发域名(从不为IP)。 它可以是单个域名(如mail.example.com )或通配符*.example.com )。 一旦你得到了你的证书解码与OpenSSL的,你可以阅读的名字,这就是所谓common name从现场cn 。 然后你只需要检查,如果你试图连接的机器是从证书之一。 既然你有远程对等的名称作为要连接到它已经,然后检查是相当琐碎,但是,这取决于你想如何偏执检查执行,您可以尝试找出如果你不使用中毒的DNS,解决您的mail.example.com主机名伪造的IP。 这应该先解决完成mail.example.com与gethostbynamel()这将给你至少有一个IP地址(假设你得到的只是1.2.3.4)。 然后,你检查反向DNS与gethostbyaddr()为每个IP地址返回,其中一人是应返回mail.example.com (请注意我用gethostbynamel()没有gethostbyname()因为它是不罕见的服务器有超过每名分配一个IP地址)。

注:请小心尝试应用过于严格的政策-可能会伤害你的用户。 它是单台服务器承载许多领域(如使用共享主机)颇为流行情况。 在使用IP这种情况下服务器1.2.3.4 ,客户的网域example.com正因为IP地址(这样解析example.com会给你1.2.3.4 ,但是撤销此主机的DNS 很可能是不同的东西,对债券ISP域名,而不是客户的域,像box0123.hosterdomain.com4-3-2-1.hosterdomain.com ,而这一切是完全正常和合法的。主机托管服务提供商这样做,因为从技术上可以分配一个IP多个域名在同一时间,但与反向DNS你只能,并通过使用自己的域名,而不是客户不需要理会无论客户添加或从服务器中删除revDNS分配IP每一个条目。

所以,如果你得到了你会被连接到主机的封闭式名单 - 你可以做这个测试,但如果你的用户可能会尝试连接的地方,那么我只是坚持只检查证书链。

编辑#1

如果你查询你不用管DNS,那么你不能完全信任它。 这样的DNS可以变成僵尸, 毒害 ,它根本就在于所有的时间和假响应你问他任何查询,无论是“向前”( FQDN到IP)和反向(IP到FQDN)。 如果DNS服务器遭到黑客攻击(源于),它可以(如果攻击者的动机不够),使之不转发in-addr.arpa查询和假匹配对方回复(更多的反应在这里反向查找 )。 所以,事实上,除非你使用DNSSEC仍然有愚弄你的支票的方式。 所以,你必须认为你需要如何偏执行动 - 前进queres可以通过DNS中毒被欺骗,而这是不工作的反向查找,如果主机是不是你的(我的意思是它的反向DNS区域托管一些其他的服务器比一个回复你的正常查询)。 你可以尝试,以确保自己agains通过即本地DNS中毒直接查询多个DNS,所以即使一个被黑客攻击,其他人可能不。 如果一切正常,所有DNSes查询应该给你相同的答案。 如果事情是腥,然后一些答复也会有所不同,你可以很容易地检测。

因此,一切都取决于你想如何安全的是,你想达到什么目的。 如果你需要高安全性,你不应该使用VPN使用的“公共”服务,并直接隧道流量,将目标服务的,即。

编辑#2

对于IPv4的IPv6的VS - PHP缺乏特点两种,所以如果你想上面的检查,提到我宁愿要打电话的工具,如host来完成这项工作(或写PHP扩展)。



文章来源: How do I verify a TLS SMTP certificate is valid in PHP?