Implementing HTTPS certificate/pubkey pinning with Ruby
I have my own HTTPS service that I am talking to from another Ruby app. I would like to save it's public key certificate at a known point in time in my app repo, and compare the public key that the service sends me with the stored copy. To install the certificate on the external server I'm likely to have to convert it to some format, so the file sent by the server is not going to be the same.
I want to do cert pinning of sorts, to that specific public key. What are the fields of the cert I need to compare using OpenSSL to verify that the PK I received from the service is the same as the one that's received from the server?
I imagine the CN and the signature have to match at least. What else needs to be checked to know that the public cert I have matches the one I've received exactly (i.e. is the same cert)? Maybe OSSL has a built-in facility for this?
Ok, after a bit of poking at OpenSSL I've arrived at the following simple implementation of public key pinning. It's actually very simple. Unfortunately I don't see popular HTTP middleware libraries (like Faraday and HTTPClient) giving access to the verify_callback which is actually available on every OpenSSL session.
In this example, the session will be terminated immediately if the PK doesn't match the one you have pinned previously. Note that the block will not be called with OpenSSL::SSL::VERIFY_NONE (which should never never never ever be used anyway).
require 'net/http' require 'openssl' # Grab the cert received out of band by pigeon post cert_code = File.read 'github.com.cer' downloaded_cert = OpenSSL::X509::Certificate.new(cert_code) # Tells us whether the private keys on the passed certificates match # and use the same algo def same_public_key?(ref_cert, actual_cert) pkr, pka = ref_cert.public_key, actual_cert.public_key # First check if the public keys use the same crypto... return false unless pkr.class == pka.class # ...and then - that they have the same contents return false unless pkr.to_pem == pka.to_pem true end # Configure a new HTTP object http = Net::HTTP.new('github.com', 443) http.use_ssl = true # We will verify against our CAs in the root store, and with VERIFY_NONE # the verify_callback will not fire at all, which defeats the purpose. http.verify_mode = OpenSSL::SSL::VERIFY_PEER # verify_callback will be called once for every certificate in the chain, # starting with the top level certificate and ending with the actual certificate # presented by the server we are contacting. Returning false from that callback # will terminate the TLS session. Exceptions within the block will be suppressed. # # Citing the Ruby OpenSSL docs: # # A callback for additional certificate verification. The callback is invoked # for each certificate in the chain. # # The callback is invoked with two values. preverify_ok indicates if the verification # was passed (true) or not (false). store_context is an OpenSSL::X509::StoreContext # containing the context used for certificate verification. # # If the callback returns false verification is stopped. http.verify_callback = lambda do | preverify_ok, cert_store | return false unless preverify_ok # We only want to verify once, and fail the first time the callback # is invoked (as opposed to checking only the last time it's called). # Therefore we get at the whole authorization chain. # The end certificate is at the beginning of the chain (the certificate # for the host we are talking to) end_cert = cert_store.chain # Only perform the checks if the current cert is the end certificate # in the chain. We can compare using the DER representation # (OpenSSL::X509::Certificate objects are not comparable, and for # a good reason). If we don't we are going to perform the verification # many times - once per certificate in the chain of trust, which is wasteful return true unless end_cert.to_der == cert_store.current_cert.to_der # And verify the public key. same_public_key?(end_cert, downloaded_cert) end # This request will fail if the cert doesn't match res = http.get '/'
If you want to do whole certificate pinning and certs are not subject to rotation, you can use cert fingerprints:
def same_cert_fingerprint?(ref, actual) OpenSSL::Digest::SHA256.hexdigest(ref.to_der) == OpenSSL::Digest::SHA256.hexdigest(actual.to_der) end
EDIT: looks like at least excon implemented this recently: