How do I verify a TLS SMTP certificate is valid in PHP?

Xeoncross picture Xeoncross · Nov 15, 2012 · Viewed 10.7k times · Source

To prevent man-in-the-middle attacks (a server pretending to be someone else), I would like to verify that the SMTP server I connect too over SSL has a valid SSL certificate which proves it is who I think it is.

For example, after connecting to an SMTP server on port 25, I can switch to a secure connection like so:

<?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");

However, there is no mention of where PHP is checking the SSL certificate against. Does PHP have a built-in list of root CA's? Is it just accepting anything?

What is the proper way to verify the certificate is valid and that the SMTP server really is who I think it is?

Update

Based on this comment on PHP.net it seems I can do SSL checks using some stream options. The best part is that the stream_context_set_option accepts a context or a stream resource. Therefore, at some point in your TCP connection you can switch to SSL using a CA cert bundle.

$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");
}

Also, see Context options and parameters which expands on the SSL options.

However, while this now solves the main problem - how do I verify that the valid certificate actually belongs to the domain/IP I'm connecting to?

In other words, the cert the server I'm connecting too may have a valid cert - but how do I know it's valid for "example.com" and not another server using a valid cert to act like "example.com"?

Update 2

It seems that you can capture the SSL certificate using the steam context params and parse it with openssl_x509_parse.

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

Answer

LSerni picture LSerni · Nov 18, 2012

UPDATE: there's a better way of doing this, see the comments.

You can capture the certificate and have a conversation with the server using openssl as a filter. This way you can extract the certificate and examine it during the same connection.

This is an incomplete implementation (the actual mail sending conversation isn't present) that ought to get you started:

<?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);
?>

Of course you will move the certificate parsing and checking before you send anything meaningful to the server.

In the $par array you should find (among the rest) the name, the same parsed as subject.

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
        )

To check for validity, apart from date checking etc., which SSL does on its own, you must verify that EITHER of these conditions apply:

  • the CN of the entity is your DNS name, e.g. "CN = smtp.your.server.com"

  • there are extensions defined and they contain a subjectAltName, which once exploded with explode(',', $subjectAltName), yield an array of DNS:-prefixed records, at least one of which matches your DNS name. If none match, the certificate is rejected.

Certificate verification in PHP

The meaning of verify host in different softwares seems murky at best.

So I decided to get at the bottom of this, and downloaded OpenSSL's source code (openssl-1.0.1c) and tried to check out for myself.

I found no references to the code I was expecting, namely:

  • attempts to parse a colon-delimited string
  • references to subjectAltName (which OpenSSL calls SN_subject_alt_name)
  • use of "DNS[:]" as delimiter

OpenSSL seems to put all certificate details into a structure, run very basic tests on some of them, but most "human readable" fields are left alone. It makes sense: it could be argued that name checking is at a higher level than certificate signature checking

I then downloaded also the latest cURL and the latest PHP tarball.

In the PHP source code I found nothing either; apparently any options are just passed down the line and otherwise ignored. This code ran with no warning:

    stream_context_set_option($smtp, 'ssl', 'I-want-a-banana', True);

and stream_context_get_options later dutifully retrieved

    [ssl] => Array
        (
            [I-want-a-banana] => 1
            ...

This too makes sense: PHP can't know, in the "context-option-setting" context, what options will be used down the line.

Just as well, the certificate parsing code parses the certificate and extracts the information OpenSSL put there, but it does not validate that same information.

So I dug a little deeper and finally found a certificate verification code in cURL, here:

// curl-7.28.0/lib/ssluse.c

static CURLcode verifyhost(struct connectdata *conn,
                       X509 *server_cert)
{

where it does what I expected: it looks for subjectAltNames, it checks all of them for sanity and runs them past hostmatch, where checks like hello.example.com == *.example.com are ran. There are additional sanity checks: "We require at least 2 dots in pattern to avoid too wide wildcard match." and xn-- checks.

To sum it up, OpenSSL runs some simple checks and leaves the rest to the caller. cURL, calling OpenSSL, implements more checks. PHP too runs some checks on CN with verify_peer, but leaves subjectAltName alone. These checks do not convince me too much; see below under "Test".

Lacking the ability to access cURL's functions, the best alternative is to reimplement those in PHP.

Variable wildcard domain matching for example could be done by dot-exploding both actual domain and certificate domain, reversing the two arrays

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

and verify that corresponding items are either equal, or the certificate one is a *; if that happens, we have to have already checked at least two components, here com and example.

I believe that the solution above is one of the best if you want to check certificates all in one go. Even better would be being able to open the stream directly without resorting to the openssl client - and this is possible; see comment.

Test

I have a good, valid, and fully trusted certificate from Thawte issued to "mail.eve.com".

The above code running on Alice would then connect securely with mail.eve.com, and it does, as expected.

Now I install that same certificate on mail.bob.com, or in some other way I convince the DNS that my server is Bob, while it actually is still Eve.

I expect the SSL connection to still work (the certificate is valid and trusted), but the certificate isn't issued to Bob -- it's issued to Eve. So someone has to make this one last check and warn Alice that Bob is actually being impersonated by Eve (or equivalently, that Bob is employing Eve's stolen certificate).

I used the code below:

    $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";

and:

  • if the certificate is not verifiable with a trusted authority:
    • verify_host does nothing
    • verify_peer TRUE causes an error
    • verify_peer FALSE allows connection
    • allow_self_signed does nothing
  • if the certificate is expired:
    • I get an error.
  • if the certificate is verifiable:
    • connection is allowed to "mail.eve.com" impersonating "mail.bob.com" and I get a "Success!" message.

I take this to mean that, barring some stupid error on my part, PHP does not by itself check certificates against names.

Using the proc_open code at the beginning of this post, I again can connect, but this time I have access to the subjectAltName and can therefore check by myself, detecting the impersonation.