SSL Pinning in iOS Swift Edition

ssl-pinning-revisited-0

[Update] As of January 2021, the code samples and the article has been updated to reflect recent changes in Alamofire 5 and Firefox. Cheers!

Some time ago, we published an article regarding the benefits of SSL pinning. If you haven’t done so already, read it – it covers a lot of basics taken for granted in this follow-up article.

Since almost the entire iOS development community has moved from Objective-C to Swift, preferences in libraries and networking have also shifted.

SSL pinning in iOS Swift

Swift-wise, our networking library of choice is usually Alamofire. As stated in the previous article, Alamofire handles pinning differently than AFNetworking and while neither implementation is wrong, sometimes you might have a preference for a certain method.

The two pinning methods

While AFNetworking talks only to the servers whose certificates you have pinned, Alamofire does it differently – you pin a certificate per domain, so the certificate check will occur only if you talk to the list of predetermined domains. For all other domains, no check will be enforced. AFNetworking, on the other hand, will block all requests that don’t pass checks for the certificates you have pinned in your app.

While Alamofire’s implementation may seem a bit weird at first glance, this kind of pinning offers greater freedom when defining different policies for different domains, e.g., a self-signed certificate for your development environment can easily be set up separately from your production environment without any preprocessor macros or common Objective-C practices.

However, for some apps, the only endpoints you want to communicate with are those you trust and have certificates for (e.g. mobile banking applications). In that case, you will have to do some handiwork to bring back the AFNetworking-like behavior.

SSL pinning cookbook

Swift SSL pinning cookbook illustration

Alright, so your app is almost ready, and you want to add that one more layer of security with SSL pinning.

Getting the certificate

If your API is live, you can easily swipe the certificate yourself using openssl:

	openssl s_client -showcerts -connect www.infinum.co:443 < /dev/null | openssl x509 -outform DER > infinumco.cer

Pinning with Swift and Alamofire

An example of security policy for Alamofire and pinning might look something like this:

	     let evaluators: [String: ServerTrustEvaluating] = [
         "infinum.com": PublicKeysTrustEvaluator()
         ]

     let manager = ServerTrustManager(evaluators: evaluators)

In this example, we’re pinning public keys from certificates. You can either pin the certificates themselves (in which case you do a byte per byte data comparison), or just compare the public keys in the certificates. Public keys have the advantage of being a bit more robust since the server certificate can be renewed retaining its public key, so no app update is needed on certificate change. In practice, this isn’t a typical case, but more on this issue later.

To replicate the AFNetworking behavior we had before, you’ll have to subclass the Server Trust Policy Manager and override the default implementation. An example that simply stops the app from communicating if there’s no pinned certificate will look similar to this:

	import Foundation
import Alamofire

final class DenyEvaluator: ServerTrustEvaluating {
    func evaluate(_ trust: SecTrust, forHost host: String) throws {
        throw AFError.serverTrustEvaluationFailed(reason: .noPublicKeysFound)
    }
}

final class CustomServerTrustPolicyManager: ServerTrustManager {
    init() {
        super.init(evaluators: [:])
    }

    override func serverTrustEvaluator(forHost host: String) throws -> ServerTrustEvaluating? {
        var policy: ServerTrustEvaluating?

        /// Smarter check would be beneficial here, theoretically, MITM attack can have an URL containing this string
        if host.contains("stackoverflow.com") {
            /// You could dig even deeper and write your own evaluator
            policy = PublicKeysTrustEvaluator()
        } else {
            /// Deny all other connections
            policy = DenyEvaluator()
        }

        return policy
    }
}

Everything is exactly the same as the default pinning implementation, except now this class needs to be used instead.

If your project uses the default networking, you have the option of not using AFNetworking or Alamofire at all, and just implementing the pinning on NSURLSession level. This is probably the cleaner way to do it than messing around with Alamofire since it allows more flexibility, and will not break in case the Alamofire API changes in the future.

You can either pin a certificate:

	// Compare the server certificate with our own stored
if let serverCertificate = SecTrustGetCertificateAtIndex(trust, 0) {
    let serverCertificateData = SecCertificateCopyData(serverCertificate) as Data

    if pinnedCertificates().contains(serverCertificateData) {
        completionHandler(.useCredential, URLCredential(trust: trust))
        return
    }
}

Or a public key:

	// Or, compare the public keys
if let serverCertificate = SecTrustGetCertificateAtIndex(trust, 0), let serverCertificateKey = publicKey(for: serverCertificate) {
    if pinnedKeys().contains(serverCertificateKey) {
        completionHandler(.useCredential, URLCredential(trust: trust))
        return
    }
}

Find the full code example in the repo here:

Common pitfalls

Testing your pin

Unlike most types of software testing where the important part is to check whether something works, for pinning, you want to test that something fails as well. Specifically, you need to test that your app cancels potentially compromised connections. While setting up a man-in-the-middle attack might be a bit of an overkill, there’s a simpler way to test, depending on which of the two versions of the pin are implemented.

If your app allows communication with only one endpoint, then testing is as simple as making a GET request to an arbitrary site (just make sure it also has a certificate). The app should cancel the connection and the request should fail. Additionally, just make sure your API works as expected and you’re set to go.

Testing the case above might be a bit more tricky if your app is pinning a certificate per domain since making an arbitrary request to another domain will succeed as expected. In this case, you might want to pin a certificate from a different domain and attempt to communicate with your API – which, again, should fail. This, in combination with a successful test with the proper certificate and successful communication with your API will usually be enough.

Handling the certificate change/update

Although renewing a certificate for a domain can retain the private/public key pair (meaning your app will continue working), this is usually not the case. Fortunately, if you plan your update cycle right, you can avoid any downtime for the end users.

Before the new certificate becomes active on the website, you should pin it in your application, along with the currently active certificate, and release an update. Pinning more than one certificate is possible and works with the code samples above. In this scenario, be mindful that you convert the certificate to a proper binary DER format.

If possible, perform a quick test of the app with the new certificate. For this test, the developers handling the certificate on the API should temporarily use the new certificate and test your app with both certificates pinned. Everything should work, and if it does, they should revert to the old certificate until the app update is ready.

Parting words

This is where I nail the last argument and entirely convince you to use SSL pinning wherever possible, but that’s almost certainly unnecessary. Some apps won’t benefit from additional security measures, but for apps involving sensitive data, an additional layer of protection is never a bad idea. In that case, I hope the article was of some use.