MAY
22
2011

Improving SSL Security with Certificate Change Notification

Improving the security of SSL is a hot topic these days, and trust in certifcate authorities is rightly at an all time low. One way of improving the situation that doesn't rely on believing that a 3rd party will actually do their job properly is to notify users when the certificate for a site changes. There are already extensions for some browsers that offer this facility, so I sat down today to write a proof of concept that looked at how this implemented in Qt.

What I've implemented is a class that can simply be plugged into QSslSocket based code to give the user a warning if the certificate for a site has changed since their last visit. The monitor class can keep track of many sockets and the cache is persistent. The idea is that the application tells the certificate monitor about the SSL socket, so that it becomes monitored by calling the addSocket() method, this method simply adds the socket to a QSignalMapper:

void SslCertificateMonitor::addSocket(QSslSocket *socket)
{
    d->mapper->setMapping(socket, socket);
    connect(socket, SIGNAL(encrypted()), d->mapper, SLOT(map()));
}

As you can see, we're connecting to the encrypted() signal of the socket in order to ensure that we get informed as soon as the SSL handshake is complete, and before any sensitive data (like cookies) are transmitted to the client. Unfortunately, this is also the cause of one of the problems of the implementation with the current Qt APIs as I'll describe later.

The core of the implementation is the socketReady() method which is called whenever the encrypted() signal for a socket we're monitoring is emitted. We'll take a look at the method a chunk at a time:

void SslCertificateMonitor::socketReady(QObject *sockobj)
{
    QSslSocket *sock = qobject_cast<QSslSocket *>(sockobj);
    if (!sock)
        return;

    QString peerName = sock->peerName();
    if (peerName == d->lastPeerAccepted)
        return; // Fast path for most recently accepted certificate

Since we're using QSignalMapper to let us know which socket we're dealing with in any given call, we first cast the QObject pointer we're given down to a QSslSocket. This means we can now call all the SSL functions. First we extract the name of the 'peer' (this is a fancy name for the site we're connecting to). The next two lines are actually part of an optimisation - since we'll often get repeated calls for the same site, I've added a simple single-entry cache that lets us quickly handle two requests that follow one after the other. A more complete implementation of this concept would probably use a slightly larger cache. If we've just accepted the site, then we know that there's no need to go through the full set of checks again, so we simply approve it immediately.

The next step is to check that the cache itself exists, and if not then create it. In this example, the cache is simply a directory that contains a file named after the host being visited. In the file, we store a cryptographic hash of the certificate that was used.

    if (!hasCertificateCache()) {
        bool ok = createCertificateCache();
        if (!ok)
            return;
    }

Finally, we get to the real meat of the class - the algorithm that determines if we should warn the user. This checks if we have a cached value, and if so checks for differences. If the certificate has changed then the signal certificateWarning() is emitted.

    QSslCertificate certificate = sock->peerCertificate();

    // Have we been here before?
    if (hasCachedCertificate(peerName)) {
        if (isMatchingCertificate(peerName, certificate)) {
            d->lastPeerAccepted = peerName;
            return; // All is well
        }

        // Cert has changed
        d->acceptCurrent = false;
        QString message = tr("The certificate for %1 has changed since you previously visited, " \
                             "it could be that someone is trying to intercept your communication.");
        message = message.arg(peerName);
        emit certificateWarning(peerName, message);
    }
    else {
        // The certificate is new. We don't show anything to user because then
        // we're simply training them to click through our warning message without
        // thinking.
        d->acceptCurrent = true;
    }

People using the class need to connect to the certificateWarning() signal, and if they wish the connection to be approved should call the acceptCertificate() method. This operates in a similar way to the QSslCertificate ignoreSslErrors() method, ie. expecting the client to use a nested event loop (such as a modal dialog) to interact with the user. If the acceptCertificate() method is called then the value of acceptCurrent will be set true. The value of this variable governs what we do next:

    // If the user has chosen to accept the certificate or the certficate is new
    // then we store the updated entry.
    if (d->acceptCurrent) {
        d->lastPeerAccepted = peerName;
        addCertificate(peerName, certificate);
    }
    else {
        // Certficate has been considered dangerous by the user
        sock->abort();
    }

The code above is the core of the entire implementation, but we should take a look at the internals of two of the functions it uses: the method for writing an entry into the cache, and the method for checking for changes. Both of the methods are very simple, adding a certificate to the cache is simply a matter of writing the certificate digest to a file:

void SslCertificateMonitor::addCertificate(const QString &peerName, const QSslCertificate &cert)
{
    QString cacheEntry = d->cacheDir + QLatin1Char('/') + peerName;
    QFile f( cacheEntry );
    if (!f.open(QIODevice::WriteOnly))
        return;

    f.write(cert.digest());
    f.close();
}

Checking if the certificate has changed is just as easy - we just compute the digest of the certificate the site has provided and compare it with the value we recorded previously:

bool SslCertificateMonitor::isMatchingCertificate(const QString &peerName, const QSslCertificate &cert)
{
    QString cacheEntry = d->cacheDir + QLatin1Char('/') + peerName;
    QFile f( cacheEntry );
    if (!f.open(QIODevice::ReadOnly))
        return false;

    QByteArray oldDigest = f.readAll();
    f.close();

    if (oldDigest != cert.digest())
        return false;

    return true;
}

In order to test the code worked, I wrote a simple app that will connect via SSL and dump the response. The code that integrates the monitor is simple:

MonitorTest::MonitorTest()
    : QWidget()
{
    monitor = new SslCertificateMonitor(this);
    connect(monitor, SIGNAL(certificateWarning(const QString &, const QString &)),
            SLOT(certificateWarning(const QString &, const QString &)));

This creates the monitor and connects the warning signal to the slot that will inform the user about the issue. When we actually create a socket, we have to call:

    monitor->addSocket(sock);

This tells the monitor to watch the certificates for our socket. Finally, we have to let the user choose what to do when a changed certificate is spotted. In the example, this is as simple as displaying a dialog:

void MonitorTest::certificateWarning(const QString &host, const QString &message)
{
    QMessageBox::StandardButton result =
        QMessageBox::warning( this,
                              tr("Certificate for %1 has changed").arg(host),
                              message + QLatin1String("\n\nAre you sure you wish to continue?"),
                              QMessageBox::Yes | QMessageBox::No );

    if (result == QMessageBox::Yes)
        monitor->acceptCertificate();
}

This means that when a certificate is found to have changed, the user sees a message like the one in the screenshot below:

As you can see, this proves that the basic concept works ok. Unfortunately there are some major API limitations that mean this code isn't as useful in practice as it could be:

  • It cannot be extended to QNetworkAccessManager right now as that neither gives us access to the underlying QSslSocket nor provides a function for encrypted() that is analogous to the sslError() forwarding function.
  • Since we need to tap the encrypted() signal, it's not safe for the application to send data in response to this signal (which is the way it is normally used). We really need to have a signal that works immediately prior to the one the application uses, or get the application to use a signal sent by this class.

In addition there are some easily fixable issues, for example it doesn't track the date or anything else from the certificate. This could easily be added though eg. by using the modification time on the cache entry. This is a good time however to be finding that there are API issues since there are plans for a reworking of the QSslSocket APIs for Qt5. Hopefully experiments like this will let us significantly improve things for the future.

The code as usual is in my qt-examples gitorious repository.

Comments

Here's a log (reproduced with permission) of a discussion about this class that took place on IRC between Sune Vuorela and myself. Hopefully it will answer a few questions other people might have:

<svuorela> can it handle several certificates for the same domain ?
<richmoore1> it can handle one certificate per peer - that's all you can
<richmoore1> mail.example.com and test.example.com can have a certificate each
<richmoore1> and it will also support the Server Name Indication extension fine
<svuorela> so if I have 100 servers behind mail.example.com with different certificates and a round-robin dns, I will get asked each time ?
<richmoore1> svuorela: that configuration is poor, but yes
<richmoore1> it could be extended to monitor past certs for a site, when it was last visited etc. but a server with the behavior you're suggesting is appearing to be risky according to this metric (quite correctly IMO)
<richmoore1> for that matter, adding a 'this site uses a configuration that makes this test unusable' would be easy too
<richmoore1> that said, i don't know anywhere that would use the config you describe
<svuorela> I have only ended up with that configuration once, and with only 2 servers. it was while changing the certificates :)
<richmoore1> which this tool would (quite intentionally) be highlilghting :-)
<richmoore1> for a production version, there are definitely some tweaks required, but i think as a proof of concept it shows that with some minor fixes to QNAM this is a usable extension
<richmoore1> eg. my single entry cache should be at least 10 for example


By Richard Moore at Sun, 05/22/2011 - 20:46

A slightly improved version is here. This one has a proper cache for the recently visited sites and always validates the certificate digest.


By Richard Moore at Sun, 05/22/2011 - 22:24

Please add support for:

Checking network notaries using Perspectives: http://www.networknotary.org/

Checking the certificate via the web of trust using Monkeysphere: http://monkeysphere.info


By bugmenot at Mon, 05/23/2011 - 02:10

That dialog is sorely missing the "Details" button which is on the invalid SSL certificate warning dialog, i.e showing what the heck the offending certificate actually is.

Even nicer would be to be able to compare the 2 certificates (and there should be a special warning if the certificate authority has the same user-visible string, but a different key fingerprint).


By Kevin Kofler at Mon, 05/23/2011 - 08:17

Yes, this is just a proof of concept. :-) There are a number of improvements to make, and showing you the certificate is definitely one of them. Caching the whole of the old cert, not just the digest is probably a worth while improvement as you say.

One thing to note is that as pointed out by Jan Kundrát the fact that v1 only checks the name of the peer on the second identical connection is a serious weakness. The version 2 I mentioned in the comment above addresses this by always verifying the digest.

There are a number of things that are needed to make this production ready, particularly making it so that it can be used with QNetworkAccessManager which will require some API changes to Qt. Fortunately I'll be at the contributor summit and we already plan to rework the SSL support then.


By Richard Moore at Mon, 05/23/2011 - 11:43