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:
- 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.