checking client ssl certificate / from python
A quick howto on checking SSL/TLS client certificates from Django/Python.
Generally, when you want to use client certificates, you’ll let the HTTPS server (e.g. NGINX) do the certificate validation.
For NGINX you’d add this config, and be done with it.
# TLS server certificate config:
...
# TLS client certificate config:
ssl_verify_client on; # or 'optional'
ssl_client_certificate /PATH/TO/my-ca.crt;
...
location ... {
...
# $ssl_client_s_dn contains: "/C=.../O=.../CN=...", where you're
# generally interested in the CN-part (commonName) to identify the
# "who".
#
# You'll want one of these, depending on your backend:
proxy_set_header X-Client-Cert-Dn $ssl_client_s_dn; # for HTTP(S)-proxy
fastcgi_param HTTP_X_CLIENT_CERT_DN $ssl_client_s_dn; # for fastcgi
uwsgi_param HTTP_X_CLIENT_CERT_DN $ssl_client_s_dn; # for uwsgi
}
The above config instruct the browser that a TLS client certificate is
needed. The supplied certificate is checked against the my-ca.crt
to
validate that my-ca.key
was used to create the certificate. The
subject line (DN) is passed along to the target (another HTTP(S)
consumer, or fastcgi, or uwsgi), where you can inspect the commonName to
see who is using the system.
This is the logical place to do things: it places the burden of all TLS handling on the world-facing proxy.
However, sometimes you want to delegate the checking to the backend. For example when the application developer is allowed to update the certificates, but not allowed to touch the ingress controller (the world-facing https proxy).
For that scenario, NGINX provides an optional_no_ca
value to the
ssl_verify_client
setting. That option instructs NGINX to allow
client certificates, and use them for TLS, but it will not check the
validity of the client certificate. In this case NGINX checks that
the client has the private key for the certificate, but it does not
check that the certificate itself is generated by the correct
certificate authority (CA) — in our case my-ca.crt
.
We change the following to the NGINX config:
# TLS client certificate config:
ssl_verify_client optional_no_ca; # there is no 'mandatory_no_ca' value?
#ssl_client_certificate # UNUSED: checked by backend
...
proxy_set_header X-Client-Cert $ssl_client_cert; # for HTTP(S)-proxy
fastcgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; # for fastcgi
uwsgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; # for uwsgi
From now on, you must validate in the backend, with something like this:
pem = environ.get('HTTP_X_CLIENT_CERT') or '<BAD_CERT>'
# or request.META['HTTP_X_CLIENT_CERT'] for Django setups
try:
cert = BaseCert.from_pem(pem)
except ValueError:
raise Http400() # cert required
try:
verify(cert)
except VerificationError:
raise Http403() # invalid client cert
Of course you’ll need an implementation of BaseCert
and verify
.
The following tlshelper3.py
uses python3-openssl
to implement
BaseCert
:
from OpenSSL.crypto import (
FILETYPE_ASN1, X509Store, X509StoreContext, X509StoreContextError,
load_certificate)
from base64 import b64decode
class VerificationError(ValueError):
pass
class BaseCert:
@classmethod
def from_pem(cls, pem_data):
try:
assert isinstance(pem_data, str), pem_data
pem_lines = [l.strip() for l in pem_data.strip().split('\n')]
assert pem_lines, 'Empty data'
assert pem_lines[0] == '-----BEGIN CERTIFICATE-----', 'Bad begin'
assert pem_lines[-1] == '-----END CERTIFICATE-----', 'Bad end'
except AssertionError as e:
raise ValueError('{} in {!r}'.format(e.args[0], pem_data)) from e
try:
der_data = b64decode(''.join(pem_lines[1:-1]))
except ValueError as e:
raise ValueError('Illegal base64 in {!r}'.format(pem_data)) from e
return cls.from_der(der_data)
@classmethod
def from_der(cls, der_data):
assert isinstance(der_data, bytes)
cert = load_certificate(FILETYPE_ASN1, der_data)
return cls(cert)
def __init__(self, x509):
self._x509 = x509
self._revoked_fingerprints = set()
def __str__(self):
try:
cn = self.get_common_name()
except Exception:
cn = '<could_not_get_common_name>'
try:
issuer = self.get_issuer_common_name()
except Exception:
issuer = '<could_not_get_issuer>'
return '{} issued by {}'.format(cn, issuer)
def get_common_name(self):
return self._get_common_name_from_components(self._x509.get_subject())
def get_fingerprints(self):
ret = {
'SHA-1': self._x509.digest('sha1').decode('ascii'),
'SHA-256': self._x509.digest('sha256').decode('ascii'),
}
assert len(ret['SHA-1']) == 59, ret
assert all(i in '0123456789ABCDEF:' for i in ret['SHA-1']), ret
assert len(ret['SHA-256']) == 95, ret
assert all(i in '0123456789ABCDEF:' for i in ret['SHA-256']), ret
return ret
def get_issuer_common_name(self):
return self._get_common_name_from_components(self._x509.get_issuer())
def _get_common_name_from_components(self, obj):
return (
# May contain other components as well, 'C', 'O', etc..
dict(obj.get_components())[b'CN'].decode('utf-8'))
def set_trusted_ca(self, cert):
self._trusted_ca = cert
def add_revoked_fingerprint(self, fingerprint_type, fingerprint):
if fingerprint_type not in ('SHA-1', 'SHA-256'):
raise ValueError('fingerprint_type should be SHA-1 or SHA-256')
fingerprint = fingerprint.upper()
assert all(i in '0123456789ABCDEF:' for i in fingerprint), fingerprint
self._revoked_fingerprints.add((fingerprint_type, fingerprint))
def verify(self):
self.verify_expiry()
self.verify_against_revoked()
self.verify_against_ca()
def verify_expiry(self):
if self._x509.has_expired():
raise VerificationError(str(self), 'is expired')
def verify_against_revoked(self):
fingerprints = self.get_fingerprints()
for fingerprint_type, fingerprint in self._revoked_fingerprints:
if fingerprints.get(fingerprint_type) == fingerprint:
raise VerificationError(
str(self), 'matches revoked fingerprint', fingerprint)
def verify_against_ca(self):
if not hasattr(self, '_trusted_ca'):
raise VerificationError(str(self), 'did not load trusted CA')
store = X509Store()
store.add_cert(self._trusted_ca._x509)
store_ctx = X509StoreContext(store, self._x509)
try:
store_ctx.verify_certificate()
except X509StoreContextError as e:
# [20, 0, 'unable to get local issuer certificate']
raise VerificationError(str(self), *e.args)
Some examples and tests:
if __name__ == '__main__':
def example():
# "Creating a CA with openssl"
# openssl genrsa -out ca.key 4096
# openssl req -new -x509 -days 365 -key ca.key -out ca.crt \
# -subj '/C=NL/CN=MY-CA'
cacert = '''-----BEGIN CERTIFICATE-----
MIIE8zCCAtugAwIBAgIJAN6Zb03+GwJUMA0GCSqGSIb3DQEBCwUAMBAxDjAMBgNV
BAMMBU1ZLUNBMB4XDTE4MDMyMzA5MTg1NFoXDTE5MDMyMzA5MTg1NFowEDEOMAwG
A1UEAwwFTVktQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCTKOv/
/rLvSh4Emdjhlsp7/1SFMlRbPJCZFHTtr0iFAENYdvMXShL/5EQVnt92e0zFD5kj
m3dx5WrKhc60CgF2fwJ9g0X64s8UQ0160BidboyLWgPQxUtYuJZfCa1Jp2at35Rb
KTTcgcvGHHM9Bl3tRvE6r3MeBtHvAgZHhjqd59g73svILVVyM0n/SHNbQiv+yOfU
87nPgbIq0hgs5v5atycFUzvzNimUH8vKmiCkYWuwM+UuHUUBDN/FESyANUJm2Eoi
hJcPnQX+JBfhGcgRUrvLiA59fMJEVU2s16vix55evnoZbe2hN2QQ9FH9LbZp6evR
qoNa9BoJVEFGHR6DCUfPDHT9EhPYe70w3Wlv3wO8vFsmKiCJivFQQCx21M8tXQug
b47x0vhbpR0gi8Cz+UsOWZvrAOKqoBGwtxEjmuc+eFKiU3h4/Mv1v3yb5W41S+eM
IGaCnXDW32X+ypHW0RirhRuRoGu67hAGVAP3KWKWuBtwaMoYErGPCSeoAy3fD0Dw
0l762mnqn5BIJmvMwjeM+CBRylXfRj/xsBs/+G6Com1zRgzkkbU+G2yYOF+2MgxK
mak/RLCx13u/VMUJDQzP3thUABCn+ZTCu+yCsFhPlj/zJU1QFu0uiGqTiqAHWYSQ
spvY6NXel2JPk/nFE1HWpyXBVyF8Ksm1XkGF8wIDAQABo1AwTjAdBgNVHQ4EFgQU
Ptqs7zPsJS7oEi76bZNHayUhzi0wHwYDVR0jBBgwFoAUPtqs7zPsJS7oEi76bZNH
ayUhzi0wDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAMBzjxsBLbXBI
TWHG4bPmHu/3Pv7p1gkiNNPh7GNA3Q9zMiN4NrstsuAFqDGBHWB5G8mfJ5v9F5qS
fX0MUQWCOqhJCopt+03l/mo6O068POZ6aGrNf9staGA1x0hJaDlAM5HusEZ6WVA4
EJySDSCmRlonzkAqOmN8mT1jzYzjCK1Q53O8/41Dv6I9RcDeU5gBs4MvFTOCmzrD
AsXX9UyOkcRMNJUBq1t9oQipciu0y2bAZSOHA0JxSiGEijRtEbnBJ1Z74orgBvYk
rPt9oEgEKkkYzT5jLL9aShSMm3UiHIhaDtCiky3qmH4GcXYZMCc3f3TF+L9Fl1YT
ExDQJvFkx1h8nWdpMFroWLX3gIawW3mWMbpokt6quW1ndnH/6i0cva7nr+5CYBJq
+RKnuF2M1z8NNDXzSLypX4MFa/LL+oj/q4r7dcELjYTClHzQ5i2ztGuyltAQSged
ECkO8b9BqXGxGbWQv4L7OXy/fjrzMw3a3ErgDcTtRdL4IUF3pTsJuhkosPSM+REs
OevV+s0sXRGRl/IlWo8mLXJp9ZKWXi+aTShitxu/FNp6LR/9/0TmVblMx0mjubfS
06lMltPa7mep4m9rfhowgf1ElSXquWTjj3bMzfvOsHrreq50NMxWCJjCeYHM2oNI
JzIhDr6afzQ62acSEV3/w7SAtkDsfFw=
-----END CERTIFICATE-----'''
# "Creating a CA-signed client cert with openssl"
# openssl genrsa -out client.key 1024
# openssl req -new -key client.key -out client.csr \
# -subj '/C=NL/CN=MY-CLIENT'
# openssl x509 -req -days 365 -in client.csr -CA ca.crt -CAkey ca.key \
# -set_serial 01 -out client.crt
clientcert = '''-----BEGIN CERTIFICATE-----
MIIDEzCB/AIBATANBgkqhkiG9w0BAQsFADAQMQ4wDAYDVQQDDAVNWS1DQTAeFw0x
ODAzMjMwOTIwNTNaFw0xOTAzMjMwOTIwNTNaMBQxEjAQBgNVBAMMCU1ZLUNMSUVO
VDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAueUyGPY5JrZcWT9MdjsxmZB/
XexDT+cKif1dxq+rxLZO7qt5jMVPZLnxCX3cypTZ1u3cvnwGkqfkYT1hRDTfs6WU
b9qwEYKz9W/9WEbh1hvVmaxRK3k+UspN1WdwOFer5k1zORzYCVZATHBj05QRztF1
+Wx9m9avXMxqLnRsRuUCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAAJ922lE2qm8k
OSCc/+BlyWJN78gxjE46S6/EnFEUgFBJhzqhIDIAApf5FDuA+5xeXt2RzrtJO/+0
vFwVuyXssbZB6R6433VN8KsyEwEp+dxaP3u4tzZ+82J6VlCDnGt1t5smXUPUzEzh
NdSeGe/11OvxKVV8b9gyy+007+l4u30vvatrpMaXRM2LpcKtmTu1B+FAPiP93G0U
vMCw6+PbMGoQitwAIHW+86aycfUzYq5mivjVaaf4wgwo3rbAwcKK8aFmCarDbtwy
cuzzvcsTdT/OxaPvGO3mOQpbcZpOFTjwNBc5LAOBRGDvbg3VOoPwOnS0lFJD5uc+
MZOKcYOmHUeKqWOyCW6svGqlvZnuDDd808tqzVnBqTYo6UoV+dj4wEL2iRE+6zFg
GuUKfbi2wV6exRisr6dBDLxIX068wbWVOHxAJrW/Ww0hKB78IqtSUXuBNuPUQg2m
8JOFkMRrNtMZCyjF+ijEEFvfvqakLk+IzXuXXDS8h0A8O7jG4ehAxe1pkbZ/g3E9
OUiJfKws5LVBLxh3HfpQe8JGfVI/5/naaqrB77gqf8Ub7YePczAEdJMiSgWBL5/l
SIW14UwkbyH6fAbbVQC5O1Px0GhpiRV0hfBLx4ZaQ5wuDU3O866endNp48Ho6mM4
/hnbcHOCf6zlThuDSGPkb76D54HdO1s=
-----END CERTIFICATE-----'''
ca = BaseCert.from_pem(cacert)
cert = BaseCert.from_pem(clientcert)
cert.set_trusted_ca(ca)
print('Certificate:', cert)
print('Fingerprints:', cert.get_fingerprints())
# cert.add_revoked_fingerprint('SHA-1',
# cert.get_fingerprints()['SHA-1'])
# cert.add_revoked_fingerprint(
# 'SHA-1',
# '05:62:27:A5:6E:A1:52:F3:E7:E7:44:16:D6:F4:BD:27:B4:D8:1B:E5')
cert.verify()
print('Verification: OK')
example()
An example verify
implementation can be found in the following sample
WSGI code — we’ll call it client-cert-wsgi.py
— runnable by e.g.
uWSGI:
from tlshelper3 import BaseCert, VerificationError
with open('/PATH/TO/my-ca.crt') as fp:
CA_CERT = BaseCert.from_pem(fp.read())
REVOKED_CERTS = (
# Example X
('SHA-256', (
'F8:7F:30:7B:12:15:15:47:07:93:D4:99:8F:7B:2E:DF:'
'12:5A:2C:0F:C4:BD:5E:56:B8:5C:93:A3:65:CB:63:9B')),
# Example Y
('SHA-256', (
'00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:'
'12:5A:2C:0F:C4:BD:5E:56:B8:5C:93:A3:65:CB:63:9B')),
# Example Z
# ('SHA-256',
# '36:9F:36:7F:0C:90:26:A1:AD:A3:79:E9:A9:8B:F5:74:'
# '21:B1:29:4B:67:73:78:B4:DE:CF:FA:C5:A6:42:BA:03'),
)
def verify(cert):
cert.set_trusted_ca(CA_CERT)
for revoked_cert in REVOKED_CERTS:
cert.add_revoked_fingerprint(*revoked_cert)
cert.verify() # raises VerificationError
def application(environ, start_response):
# Call this with: curl -E client_key_and_crt.pem URL
pem = environ.get('HTTP_X_CLIENT_CERT') or '<BAD_CERT>'
try:
cert = BaseCert.from_pem(pem)
except ValueError:
cert = None
if not cert:
return handle400(start_response)
try:
verify(cert)
except VerificationError:
return handle403(start_response)
status = '200 OK'
output = (
'Hello World!\n\nGot valid CERT {} with fingerprints:\n\n{!r}\n'
.format(cert, cert.get_fingerprints()).encode('utf-8'))
response_headers = [
('Content-type', 'text/plain'),
('Content-Length', str(len(output)))]
start_response(status, response_headers)
return [output]
I added a list of revoked certificate fingerprints in there — for the
application developer to maintain — as an easy alternative to the more
troublesome certificate revocation lists (CRLs). You can find the
certificate fingerprints using cert.get_fingerprints()
.
If you want to check the commonName (CN) to identify the who, there is
cert.get_issuer_common_name()
.
The rest of the WSGI code, for completeness sake:
def handle400(start_response):
# This does NOT cause the browser to request a client cert. We'd need
# access to the TLS layer for that, and we don't have that. The
# nginx option 'ssl_verify_client optional_no_ca' will not force a
# certicate.
#
# If you want a client.pem, you'll just concat the client.key and
# client.crt.
#
# If you want it in the browser, you'll use:
# openssl pkcs12 -export -in client.crt -inkey client.key -out client.p12
# But that's more troublesome, because -- like stated above -- the browser
# won't prompt you for a certificate.
#
status = '400 Bad Request'
output = b'400 No required SSL certificate was sent\n'
response_headers = [
('Content-type', 'text/plain'),
('Content-Length', str(len(output)))]
start_response(status, response_headers)
return [output]
def handle403(start_response):
status = '403 Access Denied'
output = b'403 Access denied to the requested resource\n'
response_headers = [
('Content-type', 'text/plain'),
('Content-Length', str(len(output)))]
start_response(status, response_headers)
return [output]
Lastly, some example uWSGI config to go with that:
[uwsgi]
plugins = python3
wsgi-file = /PATH/TO/client-cert-wsgi.py
chdir = /PATH/TO
I’ll leave creating a pretty Django middleware class as an excercise to the reader.
A word of caution: you’ll want to ensure that external/untrusted
clients cannot set X-Client-Cert
themselves — if it’s set, it must
be set by the HTTPS server handling the TLS. Otherwise a stolen/sniffed
CRT without KEY could be used sneak past the authentication.