← TLS and certificates: trust and operations
09 / 12 · 60 MIN

Real mTLS and the trust matrix

Execute handshakes with distinct client issuers and separate trust, purpose, identity and operation authorization.

Prepare disposable certificates

Save the complete code below as run.py and run python3 run.py --output evidence.json. Set DR_OPENSSL_BIN if the OpenSSL executable is elsewhere. Recorded execution used Python 3.13.1, OpenSSL CLI 3.6.1 and the OpenSSL 3.6.1 library on macOS. It creates temporary CAs and EC keys, restricts file permissions and deletes the material afterwards. Connections use only IPv4 loopback and TLS 1.3. One- or two-day certificate lifetimes serve the experiment without defining a production issuance policy.

Draw both directions

Draw two arrows before analyzing the matrix. The client verifies the server certificate using server-ca and the name api.fund.test. The server verifies client certificates using client-old-ca, client-new-ca or both. Updating one store does not automatically correct the opposite direction. The wrong-server-trust group deliberately gives the client the wrong CA and fails before the operation. The wrong-reference group retains trust but fails name verification. In a fictional incident, identify who verified which certificate under which policy before distributing additional CAs.

Predict migration across three states

Build a table with three rows: old trust, overlap and new trust. Columns represent old and new clients. In the first state only the old client receives ALLOW; during overlap both do; in the final state only the new client does. This is a planned migration in which both CAs remain approved. The table does not justify continued trust in a compromised CA. For a fictional batch service, add overnight consumers and verifier instances to the inventory. An interactive pilot does not establish that every consumer stopped depending on the old CA.

Test rejection beyond the issuer

The server requires a client certificate. A client without one is rejected, and a certificate issued by an approved CA but restricted to serverAuth also fails in the client role. Record phase, verifier error and application-read counter. A trusted CA does not remove the other validation conditions. Do not change verify_mode or purpose merely to make a negative case pass. Rejection is that case’s expected result. In the acceptance matrix, preserving correct rejection matters as much as successful operation for authorized consumers.

Separate identity from permission

The teaching application reads the verified certificate’s DNS SAN and authorizes only batch.fund.test. Two certificates with different keys and issuers retain that identifier and receive ALLOW during overlap. Another trusted certificate identifies unmapped.fund.test: the handshake finishes, but the response is DENY. This mapping is an original fixture rule, not a universal mechanism imposed by TLS. During a fictional renewal, check whether the profile changed identity and whether that change was intended. Correction needs the identity-contract or authorization owner, rather than indiscriminately widening trust.

Read outcomes from both sides

In recorded negative cases, client wrap_socket returned before the client observed server rejection during subsequent I/O. The server did not complete client validation or read the operation. Therefore, do not use only the client’s initial return as proof of accepted mTLS. Compare both sides’ logs and the required application response. Error codes and message wording can vary by runtime; retain the version and observed meaning. The fixture pins TLS 1.3 to make scope explicit and does not measure compatibility with every actual client.

"""Original bounded TLS 1.3/mTLS rollover lab, disposable PKI and IPv4 loopback.
DR_OPENSSL_BIN=/path/to/openssl python3 run.py --output evidence.json
No system trust changes, production keys, external traffic or key logging.
"""
import argparse,hashlib,json,os,pathlib,platform,shutil,socket,ssl,subprocess,tempfile,threading
from datetime import datetime,timezone


def run:
 openssl=os.environ.get('DR_OPENSSL_BIN','/opt/homebrew/bin/openssl');checks={};details={};commands=[];tracked=[];workers=[]
 def check(name,values,ok):
 checks[name]={'passed':bool(ok),**values}
 if not ok:raise AssertionError(name+': '+json.dumps(values))
 with tempfile.TemporaryDirectory(prefix='dr-tls-rollover-') as td:
 work=pathlib.Path(td);os.chmod(work,0o700);(work/'req.cnf').write_text('[req]\ndistinguished_name=dn\n[dn]\n')
 def cmd(args):
 p=subprocess.run([openssl,*args],cwd=work,capture_output=True,timeout=20);commands.append({'args':args,'exitCode':p.returncode})
 if p.returncode:raise RuntimeError(p.stderr.decode(errors='replace'))
 return p.stdout
 version=cmd(['version']).decode.strip
 def key(name):
 cmd(['genpkey','-algorithm','EC','-pkeyopt','ec_paramgen_curve:P-256','-out',name+'.key']);os.chmod(work/(name+'.key'),0o600)
 for n in ['server-ca','client-old-ca','client-new-ca']:
 key(n);cmd(['req','-new','-x509','-config','req.cnf','-key',n+'.key','-out',n+'.pem','-subj','/CN=DR synthetic '+n,'-days','2','-addext','basicConstraints=critical,CA:TRUE,pathlen:0','-addext','keyUsage=critical,keyCertSign,cRLSign','-addext','subjectKeyIdentifier=hash'])
 def issue(name,ca,serial,eku,dns):
 key(name);cmd(['req','-new','-config','req.cnf','-key',name+'.key','-out',name+'.csr','-subj','/CN=DR synthetic '+name])
 (work/(name+'.ext')).write_text('basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage='+eku+'\nsubjectAltName=DNS:'+dns+'\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid,issuer\n')
 cmd(['x509','-req','-in',name+'.csr','-CA',ca+'.pem','-CAkey',ca+'.key','-set_serial',str(serial),'-days','1','-extfile',name+'.ext','-out',name+'.pem'])
 issue('server-old','server-ca',101,'serverAuth','api.fund.test');issue('server-new','server-ca',102,'serverAuth','api.fund.test')
 issue('client-old','client-old-ca',201,'clientAuth','batch.fund.test');issue('client-new','client-new-ca',301,'clientAuth','batch.fund.test')
 issue('client-renamed','client-new-ca',302,'clientAuth','unmapped.fund.test');issue('client-wrong-purpose','client-new-ca',303,'serverAuth','batch.fund.test')
 (work/'overlap.pem').write_bytes((work/'client-old-ca.pem').read_bytes+(work/'client-new-ca.pem').read_bytes)
 def tune(ctx):
 ctx.minimum_version=ssl.TLSVersion.TLSv1_3;ctx.maximum_version=ssl.TLSVersion.TLSv1_3;ctx.verify_flags|=ssl.VERIFY_X509_STRICT
 return ctx
 def server(trust='overlap',cert='server-old',keyname=None):
 ctx=tune(ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER));ctx.verify_mode=ssl.CERT_REQUIRED;ctx.num_tickets=0
 ctx.load_cert_chain(work/(cert+'.pem'),work/((keyname or cert)+'.key'));ctx.load_verify_locations(work/(trust+'.pem'));return ctx
 def client(cert='client-new',trust='server-ca'):
 ctx=tune(ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT));ctx.hostname_checks_common_name=False;ctx.load_verify_locations(work/(trust+'.pem'))
 if cert:ctx.load_cert_chain(work/(cert+'.pem'),work/(cert+'.key'))
 return ctx
 def fingerprint(cert):return hashlib.sha256(cmd(['x509','-in',cert+'.pem','-outform','DER'])).hexdigest
 cert_hashes={n:fingerprint(n) for n in ['server-old','server-new']};details['certificateHashes']=cert_hashes
 def line(conn):
 data=bytearray
 while len(data)<64:
 b=conn.recv(1)
 if not b:raise EOFError('fixture response ended')
 data.extend(b)
 if b==b'\n':return bytes(data)
 raise ValueError('fixture line too long')
 def listener:
 s=socket.socket;tracked.append(s);s.settimeout(3);s.bind(('127.0.0.1',0));s.listen(2);return s
 def exchange(label,serverctx,clientctx,hostname='api.fund.test'):
 report={'serverHandshake':False,'clientHandshake':False,'serverApplicationReads':0,'response':None};listen=listener;report['endpoint']=listen.getsockname
 def serve:
 raw=None
 try:
 raw,_=listen.accept;tracked.append(raw);raw.settimeout(3)
 with serverctx.wrap_socket(raw,server_side=True) as secure:
 tracked.append(secure);report['serverHandshake']=True;report['serverTLS']=secure.version;report['clientNames']=[v for k,v in secure.getpeercert.get('subjectAltName',[]) if k=='DNS'];request=line(secure);report['serverApplicationReads']+=1
 response=b'ALLOW\n' if request==b'OP\n' and report['clientNames']==['batch.fund.test'] else b'DENY\n'secure.sendall(response)
 except (ssl.SSLError,OSError,EOFError) as error:report['serverError']={'type':type(error).__name__,'reason':getattr(error,'reason',None),'verifyCode':getattr(error,'verify_code',None),'message':str(error)}
 finally:
 if raw:raw.close
 worker=threading.Thread(target=serve,daemon=True);workers.append(worker);worker.start
 try:
 with socket.socket as raw:
 tracked.append(raw);raw.settimeout(3);raw.connect(listen.getsockname)
 with clientctx.wrap_socket(raw,server_hostname=hostname) as secure:
 tracked.append(secure);report['clientHandshake']=True;report['clientTLS']=secure.version;report['serverCertificateSHA256']=hashlib.sha256(secure.getpeercert(binary_form=True)).hexdigest;report['sessionReused']=secure.session_reused;secure.sendall(b'OP\n');report['response']=line(secure).decode.strip
 except (ssl.SSLError,OSError,EOFError) as error:report['clientError']={'type':type(error).__name__,'reason':getattr(error,'reason',None),'verifyCode':getattr(error,'verify_code',None),'message':str(error)}
 finally:
 worker.join(timeout=4);listen.close
 if worker.is_alive:raise RuntimeError('fixture worker did not finish')
 details[label]=report;return report
 def allowed(x):return x['serverHandshake'] and x['response']=='ALLOW'
 old=server('client-old-ca');overlap=server;new=server('client-new-ca')
 a=exchange('old-trust-old-client',old,client('client-old'));b=exchange('old-trust-new-client',old,client('client-new'))
 check('old-client-trust-does-not-admit-new-issuer',{'oldAllowed':allowed(a),'newServerHandshake':b['serverHandshake'],'newApplicationReads':b['serverApplicationReads']},allowed(a) and not b['serverHandshake'] and b['serverApplicationReads']==0)
 a=exchange('overlap-old-client',overlap,client('client-old'));b=exchange('overlap-new-client',overlap,client('client-new'))
 check('approved-overlap-admits-both-client-issuers',{'oldAllowed':allowed(a),'newAllowed':allowed(b),'sameApplicationIdentity':a.get('clientNames')==b.get('clientNames')},allowed(a) and allowed(b) and a.get('clientNames')==b.get('clientNames'))
 a=exchange('new-trust-old-client',new,client('client-old'));b=exchange('new-trust-new-client',new,client('client-new'))
 check('new-only-trust-rejects-old-client-issuer',{'oldServerHandshake':a['serverHandshake'],'oldApplicationReads':a['serverApplicationReads'],'newAllowed':allowed(b)},not a['serverHandshake'] and a['serverApplicationReads']==0 and allowed(b))
 a=exchange('no-client-certificate',overlap,client(None))
 check('required-client-certificate-is-enforced',{'serverHandshake':a['serverHandshake'],'applicationReads':a['serverApplicationReads'],'serverReason':a.get('serverError',{}).get('reason')},not a['serverHandshake'] and a['serverApplicationReads']==0 and a.get('serverError',{}).get('reason')=='PEER_DID_NOT_RETURN_A_CERTIFICATE')
 a=exchange('wrong-client-purpose',new,client('client-wrong-purpose'))
 check('trusted-issuer-does-not-replace-client-purpose',{'serverHandshake':a['serverHandshake'],'applicationReads':a['serverApplicationReads'],'verifyCode':a.get('serverError',{}).get('verifyCode')},not a['serverHandshake'] and a['serverApplicationReads']==0 and a.get('serverError',{}).get('verifyCode')==26)
 a=exchange('trusted-unmapped-identity',new,client('client-renamed'))
 check('authenticated-client-can-be-denied-by-application',{'serverHandshake':a['serverHandshake'],'clientNames':a.get('clientNames'),'applicationReads':a['serverApplicationReads'],'response':a['response']},a['serverHandshake'] and a.get('clientNames')==['unmapped.fund.test'] and a['response']=='DENY')
 a=exchange('wrong-server-trust',new,client('client-new','client-new-ca'))
 check('client-and-server-trust-directions-are-independent',{'clientHandshake':a['clientHandshake'],'applicationReads':a['serverApplicationReads'],'clientErrorType':a.get('clientError',{}).get('type')},not a['clientHandshake'] and a['serverApplicationReads']==0 and a.get('clientError',{}).get('type')=='SSLCertVerificationError')
 a=exchange('wrong-reference-name',new,client,'other.fund.test')
 check('server-name-verification-remains-enabled-during-rollover',{'clientHandshake':a['clientHandshake'],'applicationReads':a['serverApplicationReads'],'verifyCode':a.get('clientError',{}).get('verifyCode')},not a['clientHandshake'] and a['serverApplicationReads']==0 and a.get('clientError',{}).get('verifyCode')==62)
 for ext in ['pem','key']:shutil.copyfile(work/('server-old.'+ext),work/('deployed.'+ext))
 os.chmod(work/'deployed.key',0o600);loaded=server(cert='deployed')
 for ext in ['pem','key']:shutil.copyfile(work/('server-new.'+ext),work/('deployed.'+ext))
 a=exchange('old-context-after-file-replacement',loaded,client);fresh=server(cert='deployed');b=exchange('fresh-context-after-file-replacement',fresh,client)
 check('replacing-certificate-files-does-not-reload-existing-context',{'diskMatchesNewCertificate':fingerprint('deployed')==cert_hashes['server-new'],'oldContextServesOld':a.get('serverCertificateSHA256')==cert_hashes['server-old'],'freshContextServesNew':b.get('serverCertificateSHA256')==cert_hashes['server-new'],'bothAllowed':allowed(a) and allowed(b)},fingerprint('deployed')==cert_hashes['server-new'] and a.get('serverCertificateSHA256')==cert_hashes['server-old'] and b.get('serverCertificateSHA256')==cert_hashes['server-new'] and allowed(a) and allowed(b))
 try:server(cert='server-new',keyname='server-old')
 except ssl.SSLError as error:reason=error.reason
 else:raise AssertionError('mismatched certificate/key loaded')
 check('mismatched-key-is-rejected-before-serving',{'reason':reason,'listenerCreatedForMismatch':False},reason=='KEY_VALUES_MISMATCH')
 shutil.copyfile(work/'client-old-ca.pem',work/'deployed-trust.pem');loaded=server(trust='deployed-trust');shutil.copyfile(work/'client-new-ca.pem',work/'deployed-trust.pem')
 a=exchange('old-context-after-trust-file-replacement',loaded,client('client-old'));fresh=server(trust='deployed-trust');b=exchange('fresh-context-old-client',fresh,client('client-old'));c=exchange('fresh-context-new-client',fresh,client)
 check('replacing-trust-file-does-not-retire-loaded-anchor',{'oldContextStillAllowsOld':allowed(a),'freshContextRejectsOld':not b['serverHandshake'],'freshContextAllowsNew':allowed(c)},allowed(a) and not b['serverHandshake'] and allowed(c))
 listen=listener;state={}
 def established_server:
 raw=None
 try:
 raw,_=listen.accept;tracked.append(raw);raw.settimeout(3)
 with overlap.wrap_socket(raw,server_side=True) as secure:
 tracked.append(secure);state['peerNames']=[v for k,v in secure.getpeercert.get('subjectAltName',[]) if k=='DNS']
 for _ in range(2):assert line(secure)==b'OP\n'secure.sendall(b'EXISTING-OK\n')
 except Exception as error:state['error']=str(error)
 finally:
 if raw:raw.close
 worker=threading.Thread(target=established_server,daemon=True);workers.append(worker);worker.start
 try:
 with socket.socket as raw:
 tracked.append(raw);raw.settimeout(3);raw.connect(listen.getsockname)
 with client('client-old').wrap_socket(raw,server_hostname='api.fund.test') as secure:
 tracked.append(secure);secure.sendall(b'OP\n');first=line(secure);probe=exchange('new-policy-probe-while-old-connection-open',new,client('client-old'));secure.sendall(b'OP\n');second=line(secure)
 finally:
 worker.join(timeout=4);listen.close
 if worker.is_alive:raise RuntimeError('persistent fixture did not finish')
 details['establishedConnection']=state
 check('new-context-policy-does-not-terminate-an-existing-connection',{'firstResponse':first.decode.strip,'newProbeRejected':not probe['serverHandshake'],'secondResponse':second.decode.strip,'oldConnectionReauthenticated':False,'separateProbeListener':True},first==second==b'EXISTING-OK\n' and not probe['serverHandshake'] and 'error' not in state)
 result={'executedAt':datetime.now(timezone.utc).isoformat,'pythonVersion':platform.python_version,'platform':platform.platform,'opensslCLI':version,'opensslLibrary':ssl.OPENSSL_VERSION,'checks':checks,'details':details,'commands':commands,'passed':sum(x['passed'] for x in checks.values),'failed':sum(not x['passed'] for x in checks.values),'allSocketsClosed':all(s.fileno==-1 for s in tracked),'allWorkersStopped':all(not t.is_alive for t in workers),'temporaryMaterialDeleted':not pathlib.Path(td).exists,'scriptSha256':hashlib.sha256(pathlib.Path(__file__).read_bytes).hexdigest,'scope':'Actual TLS 1.3 full handshakes on synthetic IPv4 loopback endpoints with disposable EC certificates. Explicit client/server trust and SAN-based teaching authorization; no system trust changes or secret output. Approved-CA overlap only, not a compromised-CA recovery policy. No CRL/OCSP, session resumption, proxy, Kubernetes deployment, external traffic, production workload or human workshop. Contexts are recreated rather than mutated after use. The established-connection test probes new policy on a separate listener; it does not perform a production hot reload.'}
 assert result['allSocketsClosed'] and result['allWorkersStopped'] and result['temporaryMaterialDeleted'];return result

if __name__=='__main__':
 p=argparse.ArgumentParser;p.add_argument('--output',required=True);args=p.parse_args;result=run;pathlib.Path(args.output).write_text(json.dumps(result,indent=2)+'\n');print(json.dumps({k:result[k] for k in ['passed','failed','allSocketsClosed','allWorkersStopped','temporaryMaterialDeleted']}))
IN PRACTICE

Exercise: fill the old/overlap/new matrix for two clients, then add a missing certificate, wrong purpose and trusted identity without permission. Predict handshake and response separately.

Common pitfalls

Swapping trust directions, validating only one pilot, turning negative cases into success or confusing authentication with operation authorization.

Related topics: Negotiation, mTLS, and the application · Renewal and served certificates · Diagnosis with explicit criteria

Take this idea with you

The matrix should establish who is accepted and rejected in each phase while keeping trust, identity and permission separate.

Create account

Reference: Python ssl: TLS contexts and verification · BigSavant TLS/certificates 2026-09; selected TLS 1.2/1.3, RFC 9525 identity and OpenSSL 3.5 diagnostics