Skip to content

Instantly share code, notes, and snippets.

@dlenski
Last active February 26, 2024 09:05
Show Gist options
  • Save dlenski/fc42156c00a615f4aa18a6d19d67e208 to your computer and use it in GitHub Desktop.
Save dlenski/fc42156c00a615f4aa18a6d19d67e208 to your computer and use it in GitHub Desktop.
Fingerprint-based certificate validation in Python (including pin-sha256)
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# This is a demonstration of how to do fingerprint-based certificate
# validation in Python, in the style of OpenConnect:
# https://gitlab.com/openconnect/openconnect/-/blob/HEAD/library.c#L1084-1143
#
# For Python <3.7, we monkey-patch ssl.SSLSocket directly, because ssl.SSLContext.sslsocket_class
# isn't available until Python 3.7. For Python 3.7+, we set ssl.SSLContext.sslsocket_class
# to our modified version (which is sort of monkey-patching too).
# https://docs.python.org/3/library/ssl.html#ssl.SSLContext.wrap_socket
#
# Requires asn1crypto module to extract the public key from a server's certificate.
# (The older pyasn1 module does not get this right for non-RSA certs.)
#
# Alternative approach to doing this:
# https://medium.com/@jbirdvegas/python-certificate-pinning-c44e9a34ed1c
import re
import base64
import ssl
import hashlib
import urllib.request
import sys
import asn1crypto.x509
def fingerprint_checking_SSLSocket(_fingerprints):
class SSLSocket(ssl.SSLSocket):
fingerprints = _fingerprints
def do_handshake(self, *args, **kw):
res = super().do_handshake(*args, **kw)
der_bytes = self.getpeercert(True)
cert = asn1crypto.x509.Certificate.load(der_bytes)
pubkey = cert.public_key.dump() #
crt_sha1 = hashlib.sha1(der_bytes).hexdigest()
pin_sha256 = base64.b64encode(hashlib.sha256(pubkey).digest()).decode()
if 'pin-sha256:' + pin_sha256 in self.fingerprints:
print("Server %r public key fingerprint pin-sha256:%s matches" % (self.server_hostname, pin_sha256))
elif crt_sha1.lower() in map(str.lower, self.fingerprints):
print("Server %r certificate fingerprint (sha1) %s matches" % (self.server_hostname, crt_sha1))
else:
raise RuntimeError("Server %r fingerprints (pin-sha256:%s, %s) do not match any of %r" % (
self.server_hostname, pin_sha256, crt_sha1, self.fingerprints), der_bytes, pin_sha256, crt_sha1)
return SSLSocket
########################################
fingerprints = [
'pin-sha256:zi6KtWUsUdqG4LN3DOuBQ+NHmIVobjP7ayR5lRvxMEY=', # gnutls-cli www.google.com
'pin-sha256:WRY+Z3yTDcTBKMgPxoFDmwSMgCVZ5FQDGE77XogZT1w=', # gnutls-cli www.infradead.org
'pin-sha256:j4i4cwAEhF/BFKbkqi8WZWur4bMvr2/EbovM0ku1FNc=', # gnutls-cli 172.217.14.228 (= www.google.com)
'pin-sha256:RKlx+/Jwn2A+dVoU8gQWeRN2+2JxXcFkAczKfgU8OAI=', # gnutls-cli 1.1.1.1 (= CloudFlare pub DNS)
'pin-sha256:prcq5GFQRMNeJ2iinjYKBGwVXGXgbBg0idTFUPXRo3g=', # gnutls-cli 8.8.8.8 (= dns.google pub DNS)
'fca7e7a063faa5bbd2d940424162c5d82e92909f', # openssl s_client -connect www.w3.org:443 | openssl x509 -sha1 -fingerprint -noout
]
test_servers = [
'www.google.com',
'www.infradead.org',
'90.155.50.34', # = infradead.org
'172.217.14.228', # = www.google.com, gives a different cert from gnutls-cli
# because it bizarrely objects to Python 3.6's SNI-less
# ClientHello, but not to gnutls-cli's version
'www.w3.org',
'8.8.8.8',
'1.1.1.1',
]
########################################
# monkey-patch ssl.SSLSocket
print(sys.version_info)
if sys.version_info >= (3, 7):
ssl.SSLContext.sslsocket_class = fingerprint_checking_SSLSocket(fingerprints)
else:
ssl.SSLSocket = fingerprint_checking_SSLSocket(fingerprints)
ssl._create_default_https_context = ssl._create_unverified_context
for ts in test_servers:
print('***** %s *****' % ts)
try:
r = urllib.request.Request(url='https://' + ts, headers={'User-Agent': 'curl/9.1'})
urllib.request.urlopen(r)
except RuntimeError as e:
msg, der_bytes, *fingerprints = e.args
fn = '/tmp/bad_%s.der' % ts
with open(fn, 'wb') as der:
der.write(der_bytes)
print("ERROR: %s (saved cert as %s)" % (msg, fn))
@dlenski
Copy link
Author

dlenski commented Oct 11, 2023

Requires asn1crypto module to extract the public key from a server's certificate.
(The older pyasn1 module does not get this right for non-RSA certs.)

Quick hacky script to demonstrate this. Pass it a non-RSA cert:

#!/usr/bin/python3

import sys
import hashlib

########################################

import pyasn1.codec.der.decoder
import pyasn1.codec.der.encoder
import pyasn1_modules.pem
import pyasn1_modules.rfc2459

f = open(sys.argv[1], 'r')

print("With pyasn1:")
substrate = pyasn1_modules.pem.readPemFromFile(f)
cert, _ = pyasn1.codec.der.decoder.decode(substrate, pyasn1_modules.rfc2459.Certificate())

fingerprint = hashlib.sha256(substrate).hexdigest()
print("**** cert sha256: ", fingerprint)

b = cert.getComponentByName('tbsCertificate').getComponentByName('subjectPublicKeyInfo').getComponentByName('subjectPublicKey').asOctets()
#open('/tmp/xyz.der', 'wb').write(pyasn1.codec.der.encoder.encode(b))
fingerprint = hashlib.sha256(b).hexdigest()
print("**** cert PK sha256: ", fingerprint)

########################################

from asn1crypto import pem, x509

f = open(sys.argv[1], 'rb')

print("With asn1crypto:")
der_bytes = f.read()
if pem.detect(der_bytes):
    type_name, headers, der_bytes = pem.unarmor(der_bytes)
    print(type_name, headers)

cert = x509.Certificate.load(der_bytes)

fingerprint = hashlib.sha256(cert.dump()).hexdigest()
print("**** cert sha256: ", fingerprint)

open('/tmp/xyz2.der', 'wb').write(cert.public_key.dump())

fingerprint = hashlib.sha256(cert.public_key.dump()).hexdigest()
print("**** cert PK sha256: ", fingerprint)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment