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

CRLs: policy, time and distribution

Execute CRL checks and distinguish loaded material, active policy, time validity and information actually received.

Prepare a disposable PKI

Save the code below as run.py and run python3 run.py --output evidence.json. Set DR_OPENSSL_BIN if OpenSSL is elsewhere. Recorded execution uses Python 3.13.1 and OpenSSL 3.6.1 on macOS. It creates two temporary CAs and two leaf certificates issued by the main CA, sharing SAN api.fund.test. One leaf remains valid in the index and the other is revoked. Keys and the CA database are deleted afterwards. This is a teaching design using offline processing and direct issuance, without a responder service or system-trust changes.

Predict before executing

Before reading the JSON, draw a matrix with good and revoked leaves as rows. Columns should cover current CRL with checking enabled, loaded CRL without checking, and missing CRL with checking required. Predict acceptance and rejection in each cell. In the experiment, -CRLfile supplies the object while -crl_check enables leaf checking. This distinction helps in a fictional case where a team delivers files to every instance but the control remains inactive. A change should close only when configuration and probes demonstrate its intended effect.

Interpret rejection

With the current CRL and policy enabled, the unrevoked leaf passes and the other is rejected with certificate revoked. When an applicable CRL is missing, the error is unable to get certificate CRL. These observations have different meanings. The first identifies reported negative status; the second shows that information required for a decision is missing. In a fictional incident, record issuer, serial, policy, consulted artifact and error. Do not turn unavailable information into positive confirmation or declare every certificate revoked because distribution failed.

Separate artifact timelines

The code generates an expired and a future CRL using relative dates. The first has nextUpdate in the past; the second has lastUpdate in the future. The system clock remains unchanged. Both cases are rejected even though the leaf remains within its own validity interval. For a fictional batch interrupted at two in the morning, first compare the effective clock and object times. If the source was not refreshed, recover the job or approved alternative path. Changing the clock to hide the error can affect other services and does not recover distribution.

Observe different snapshots

before.pem was issued before recording revocation; current.pem was issued afterwards. Both are time-acceptable during the experiment, but only the second contains the event. The revoked leaf passes with the first and fails with the second. This does not establish that newest status reaches every consumer instantly. For an actual change, define publication, refresh, propagation and alert criteria. Exposure depends on those mechanisms and applicable policy. Add overnight consumers to the inventory so that an interactive pilot does not hide older copies still being used.

Retain integrity and scope

The integrity group changes one byte of the DER CRL signature and converts the object back to PEM. The structure remains readable, but verification rejects the signature. Reading an object is not equivalent to trusting it. The lab also does not establish revocation checking for every intermediate, indirect or delta CRLs, or TLS endpoint behavior. The root issues leaves directly and selected checking covers the leaf. Explain these boundaries during handover and request separate evidence for the actual chain. Recovery after key compromise should exclude the exposed pair from approved rollback.

"""Original DR offline CRL/OCSP experiment. Disposable PKI, no network or TLS endpoint."""
import argparse
import datetime as dt
import hashlib
import json
import os
from pathlib import Path
import platform
import re
import subprocess
import tempfile

OPENSSL = os.environ.get('DR_OPENSSL_BIN', '/opt/homebrew/bin/openssl')
evidence = {'executedAt': dt.datetime.now(dt.timezone.utc).isoformat,
 'pythonVersion': platform.python_version, 'platform': platform.platform,
 'commands': [], 'checks': {}, 'observations': {}}

def execute(*args, ok=False):
 result = subprocess.run([OPENSSL, *args], cwd=base, capture_output=True, timeout=15)
 text = (result.stdout + result.stderr).decode('utf-8', errors='replace')
 evidence['commands'].append({'args': list(args), 'exitCode': result.returncode})
 if ok and result.returncode:
 raise RuntimeError('Command failed: ' + ' '.join(args) + '\n' + text)
 return result.returncode, text

def write(name, value):
 (base/name).write_text(value)
 (base/name).chmod(0o600)

def observe(name, result):
 code, text = result
 evidence['observations'][name] = {'exitCode': code, 'output': text}
 return code, text

def check(name, condition, **facts):
 evidence['checks'][name] = {'passed': bool(condition), **facts}

def crl(name, start, end):
 execute('ca', '-batch', '-config', 'ca.cnf', '-gencrl', '-crl_lastupdate', stamp(start),
 '-crl_nextupdate', stamp(end), '-out', name, ok=True)

def verify(cert, *args):
 return execute('verify', '-CAfile', 'ca.pem', '-no-CApath', '-no-CAstore',
 '-purpose', 'sslserver', '-verify_hostname', 'api.fund.test', *args, cert)

def request(name, *args):
 execute('ocsp', '-issuer', 'ca.pem', *args, '-reqout', name, ok=True)

def response(req, name):
 execute('ocsp', '-index', 'index.txt', '-rsigner', 'ca.pem', '-rkey', 'ca.key',
 '-CA', 'ca.pem', '-reqin', req, '-respout', name, '-ndays', '1', ok=True)

def inspect(req, resp, trust='ca.pem'):
 return execute('ocsp', '-reqin', req, '-respin', resp, '-CAfile', trust,
 '-no-CApath', '-no-CAstore')

parser = argparse.ArgumentParser
parser.add_argument('--output', default='evidence.json')
output = Path(parser.parse_args.output).resolve
with tempfile.TemporaryDirectory(prefix='dr-tls-status-') as directory:
 base = Path(directory)
 base.chmod(0o700)
 evidence['opensslCLI'] = execute('version', ok=True)[1].strip
 now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
 stamp = lambda seconds: (now + dt.timedelta(seconds=seconds)).strftime('%Y%m%d%H%M%SZ')
 write('req.cnf', '[req]\ndistinguished_name=dn\n[dn]\n')
 for name in ['ca', 'wrong-ca', 'good', 'revoked']:
 execute('genpkey', '-algorithm', 'EC', '-pkeyopt', 'ec_paramgen_curve:P-256', '-out', name+'.key', ok=True)
 (base/(name+'.key')).chmod(0o600)
 for name in ['ca', 'wrong-ca']:
 execute('req', '-new', '-x509', '-config', 'req.cnf', '-key', name+'.key',
 '-out', name+'.pem', '-subj', '/CN=DR synthetic '+name, '-days', '3',
 '-addext', 'basicConstraints=critical,CA:TRUE',
 '-addext', 'keyUsage=critical,keyCertSign,cRLSign',
 '-addext', 'subjectKeyIdentifier=hash', ok=True)
 write('index.txt', '')
 write('serial', '1000\n')
 write('crlnumber', '01\n')
 (base/'newcerts').mkdir
 write('ca.cnf', '''[ca]
default_ca=fixture
[fixture]
database=index.txt
new_certs_dir=newcerts
certificate=ca.pem
private_key=ca.key
serial=serial
crlnumber=crlnumber
default_md=sha256
default_days=2
default_crl_days=1
policy=policy
unique_subject=no
x509_extensions=leaf
crl_extensions=crl_ext
[policy]
commonName=supplied
[leaf]
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature
extendedKeyUsage=serverAuth
subjectAltName=DNS:api.fund.test
subjectKeyIdentifier=hash
authorityKeyIdentifier=keyid
[crl_ext]
authorityKeyIdentifier=keyid:always
''')
 for name in ['good', 'revoked']:
 execute('req', '-new', '-config', 'req.cnf', '-key', name+'.key', '-out', name+'.csr',
 '-subj', '/CN=DR synthetic '+name, ok=True)
 execute('ca', '-batch', '-config', 'ca.cnf', '-in', name+'.csr', '-out', name+'.pem',
 '-notext', ok=True)
 crl('before.pem', -60, 3600)
 execute('ca', '-batch', '-config', 'ca.cnf', '-revoke', 'revoked.pem', '-crl_reason', 'keyCompromise', ok=True)
 crl('current.pem', -30, 3600)
 crl('expired.pem', -7200, -3600)
 crl('future.pem', 3600, 7200)
 code, text = observe('no-crl-policy', verify('revoked.pem', '-CRLfile', 'current.pem'))
 check('loading-crl-does-not-enable-revocation-check', code == 0, exitCode=code, crlLoaded=True, checkEnabled=False)
 code, text = observe('good-with-current-crl', verify('good.pem', '-crl_check', '-CRLfile', 'current.pem'))
 check('unrevoked-leaf-passes-explicit-crl-check', code == 0, exitCode=code)
 code, text = observe('revoked-with-current-crl', verify('revoked.pem', '-crl_check', '-CRLfile', 'current.pem'))
 check('revoked-leaf-fails-explicit-crl-check', code!= 0 and 'certificate revoked' in text, exitCode=code, revokedReported='certificate revoked' in text)
 code, text = observe('missing-crl', verify('good.pem', '-crl_check'))
 check('missing-crl-is-not-a-good-status', code!= 0 and 'unable to get certificate CRL' in text, exitCode=code, missingReported='unable to get certificate CRL' in text)
 code, text = observe('expired-crl', verify('good.pem', '-crl_check', '-CRLfile', 'expired.pem'))
 check('expired-crl-is-rejected', code!= 0 and 'CRL has expired' in text, exitCode=code, expiredReported='CRL has expired' in text)
 code, text = observe('future-crl', verify('good.pem', '-crl_check', '-CRLfile', 'future.pem'))
 check('future-crl-is-rejected', code!= 0 and 'CRL is not yet valid' in text, exitCode=code, futureReported='CRL is not yet valid' in text)
 code, text = observe('older-still-valid-crl', verify('revoked.pem', '-crl_check', '-CRLfile', 'before.pem'))
 check('older-valid-crl-does-not-contain-later-revocation', code == 0, exitCode=code, olderSnapshot=True, newestStateKnown=False)
 execute('crl', '-in', 'current.pem', '-outform', 'DER', '-out', 'current.der', ok=True)
 data = bytearray((base/'current.der').read_bytes); data[-1] ^= 1
 (base/'tampered.der').write_bytes(data)
 execute('crl', '-inform', 'DER', '-in', 'tampered.der', '-out', 'tampered.pem', ok=True)
 code, text = observe('tampered-crl', verify('good.pem', '-crl_check', '-CRLfile', 'tampered.pem'))
 check('altered-crl-signature-is-rejected', code!= 0 and 'CRL signature failure' in text, exitCode=code, signatureFailure='CRL signature failure' in text)
 request('batch.req', '-cert', 'good.pem', '-cert', 'revoked.pem', '-serial', '9999')
 response('batch.req', 'batch.resp')
 code, text = observe('verified-ocsp-batch', inspect('batch.req', 'batch.resp'))
 verified = code == 0 and 'Response verify OK' in text
 # Report statuses for named CertIDs separately; nonce binding was checked above.
 code, text = observe('ocsp-certificate-statuses', execute('ocsp', '-issuer', 'ca.pem',
 '-cert', 'good.pem', '-cert', 'revoked.pem', '-serial', '9999', '-respin', 'batch.resp',
 '-CAfile', 'ca.pem', '-no-CApath', '-no-CAstore', '-no_nonce'))
 verified = verified and 'Response verify OK' in text
 check('ocsp-good-is-a-certificate-status', verified and 'good.pem: good' in text, responseVerified=verified, goodReported='good.pem: good' in text)
 check('verified-ocsp-response-can-report-revoked', verified and 'revoked.pem: revoked' in text, responseVerified=verified, revokedReported='revoked.pem: revoked' in text, exitCode=code)
 check('verified-ocsp-response-can-report-unknown', verified and '9999: unknown' in text, responseVerified=verified, unknownReported='9999: unknown' in text, exitCode=code)
 code, text = observe('wrong-ocsp-trust', inspect('batch.req', 'batch.resp', 'wrong-ca.pem'))
 check('ocsp-response-signature-needs-approved-trust', code!= 0 and 'Response Verify Failure' in text, exitCode=code, verificationFailed='Response Verify Failure' in text)
 request('other-nonce.req', '-cert', 'good.pem', '-cert', 'revoked.pem', '-serial', '9999')
 code, text = observe('mismatched-nonce', inspect('other-nonce.req', 'batch.resp'))
 check('different-request-nonce-is-rejected', code!= 0 and 'Nonce Verify error' in text, exitCode=code, nonceMismatch='Nonce Verify error' in text)
 request('good-only.req', '-cert', 'good.pem')
 response('good-only.req', 'good-only.resp')
 # Suppress nonce comparison only for this deliberate CertID mismatch probe.
 # Signature verification stays enabled. This is not a production policy.
 code, text = observe('missing-certid', execute('ocsp', '-issuer', 'ca.pem', '-cert', 'revoked.pem',
 '-respin', 'good-only.resp', '-CAfile', 'ca.pem', '-no-CApath', '-no-CAstore', '-no_nonce'))
 check('another-certificate-response-is-not-applicable', 'Response verify OK' in text and 'No Status found' in text,
 responseVerified='Response verify OK' in text, statusMissing='No Status found' in text, exitCode=code)
 evidence['publicArtifactHashes'] = {name: hashlib.sha256((base/name).read_bytes).hexdigest
 for name in ['before.pem', 'current.pem', 'expired.pem', 'future.pem', 'batch.req', 'batch.resp']}
evidence['temporaryMaterialDeleted'] = not base.exists
evidence['systemClockChanged'] = False
evidence['networkUsed'] = False
evidence['scriptSha256'] = hashlib.sha256(Path(__file__).read_bytes).hexdigest
evidence['passed'] = sum(c['passed'] for c in evidence['checks'].values)
evidence['failed'] = len(evidence['checks']) - evidence['passed']
evidence['scope'] = ('Actual offline OpenSSL CRL generation/verification and OCSP request/signed-response processing. '
 'Disposable direct CA-issued leaves, issuer-signed responses and local files only. '
 'No responder service, HTTP cache, TLS stapling, delegated responder, full-chain CRL coverage, '
 'delta/indirect CRLs, session resumption, existing-connection revocation, production integration or human workshop. '
 'CRL timestamps are generated around the current instant; the system clock is never changed. '
 'No private keys are exported into evidence.')
output.write_text(json.dumps(evidence, indent=2)+'\n')
print(json.dumps({'passed': evidence['passed'], 'failed': evidence['failed'],
 'failedChecks': [n for n,c in evidence['checks'].items if not c['passed']],
 'temporaryMaterialDeleted': evidence['temporaryMaterialDeleted']}))
raise SystemExit(bool(evidence['failed']))
IN PRACTICE

Exercise: the CRL reached three instances, but one still lacks active checking and another retains before.pem. Fill the probe matrix and define closure criteria per instance.

Common pitfalls

Confusing a file with active control, missing CRL with revocation, time validity with newest status, or PEM parsing with a valid signature.

Related topics: Identity, name, and time · Revocation and trust transition · Activation, contexts and existing connections

Take this idea with you

The decision depends on active policy and authentic, applicable, time-acceptable information; distribution needs evidence per consumer.

Create account

Reference: OpenSSL verify: explicit certificate checks · BigSavant TLS/certificates 2026-09; selected TLS 1.2/1.3, RFC 9525 identity and OpenSSL 3.5 diagnostics