A CA bundle can show root B on disk while an already-created Python SSLContext still accepts a server leaf issued by retired root A. That is the operational trap this article qualifies. The file, the context’s loaded trust objects and a new TLS handshake are three different evidence surfaces. None should be substituted for another.
The policy here is intentionally narrow: two disposable owned roots, A and B; two server leaves with the same service.lab.test Subject Alternative Name; explicit cafile loading; TLS 1.3 only; sequential MemoryBIO handshakes with new SSLObject instances; and a factory baseline that is measured rather than erased. Python’s ssl documentation describes load_verify_locations(), certificate-store inspection and MemoryBIO/SSLObject operation, while OpenSSL documents that a CA file is processed when the loading call executes.
The preparation run supplied for this article finished with exit code 0 and PASS_10_CASES on CPython 3.12.14. Its verification backend was OpenSSL 3.5.8; the separate Cryptography 50.0.2 generation backend reported OpenSSL 4.0.3. Those versions must not be collapsed into one runtime claim. The review outcome is also separate from the diagnostic label: a replacement candidate can be ACCEPT, a disk-only or additive-reload strategy can require REPAIR, and unresolved provenance or policy evidence requires HOLD.
Define what retiring root A must mean
“Rotate the CA bundle” is not a sufficient acceptance criterion. The application policy has to say what the resulting client is permitted to trust.
For this experiment, retirement has three conditions. The candidate client context must reject the otherwise-valid server leaf issued directly by owned root A. It must accept the corresponding leaf issued by owned root B. It must retain the factory baseline observed before either owned root is loaded. That baseline is not declared globally correct by this test; it is simply held constant so the A/B state transition can be reviewed without quietly deleting unrelated runtime trust objects.
That differs from an overlap policy. An organization might deliberately authorize A+B during a staged migration. Under that separate policy, T07/T08 below could be a valid intermediate state. It is not valid under the policy exercised here, because A has been declared retired. Changing policy after seeing the result would turn the test oracle into a description of current behavior rather than an independent requirement.
This client-side trust decision fits within the broader TLS certificate lifecycle, but it is not about public issuance, ACME, validity-period automation or deployment of a new server certificate. Refonte Learning’s lifecycle article explicitly scopes its central mechanism to public server-certificate operations; this laboratory instead examines private trust already loaded into a Python client object.
The review vocabulary is therefore established before the experiment:
Decision | Meaning in this article |
ACCEPT | A named replacement candidate has complete provenance, rejects A for the expected issuer reason, accepts B, and retains the measured baseline under the recorded settings. |
REPAIR | The observed mechanism contradicts the declared retirement policy but the failure mode is understood, such as editing the file without rebuilding the context or reloading B additively into a context that still contains A. |
HOLD | Baseline, candidate identity, settings, rejection reason, case completeness or policy is unresolved. |
A PASS row means a diagnostic assertion behaved as expected. It does not itself approve a deployment.
Separate the bundle, the store and the handshake
There are three state transitions to track.
First, the CA bundle is a file. Python documents SSLContext.load_verify_locations(cafile=...) as loading CA certificates used to validate peers. OpenSSL 3.5 documents the underlying CA-file model more explicitly: the CA file is processed when SSL_CTX_load_verify_locations() or the file-specific loading function executes. That is materially different from a directory lookup mechanism, where certificates may be located on demand.
Second, the context contains loaded verification material. Python exposes get_ca_certs() and cert_store_stats() for observing loaded certificates and store counts. Its documentation also cautions that an SSLContext supports only limited mutation after use; adding certificates to the internal trust store is expressly allowed, whereas reconfiguring other properties after connection use can produce surprising behavior.
Third, an individual TLS handshake asks whether a presented peer is accepted under that context. The laboratory creates new SSLObject instances with wrap_bio() and drives them through MemoryBIO buffers, which Python documents as an in-memory TLS mechanism with no socket network I/O.
These surfaces answer different questions. “The file contains B” proves the selected file bytes. “The context inventory contains B” proves a certificate object with B’s DER bytes is loaded. “The B-issued peer completed verification” proves that one controlled handshake succeeded under the recorded conditions. None of those statements proves A was removed.
load_cert_chain() is unrelated to the client trust replacement at issue. In this fixture it configures each synthetic server with its leaf and private key. Python documents it as loading a local certificate/private-key pair, whereas load_verify_locations() supplies peer-verification CA material.
Keep the server name correct throughout the experiment
Both owned leaves contain DNS:service.lab.test; client contexts retain check_hostname=True; and each client wrap_bio() call supplies server_hostname=HOST. That controls identity while issuer trust changes. Python’s PROTOCOL_TLS_CLIENT enables certificate verification and hostname checking by default, and the 3.12 documentation records a wrap_bio() validation change in 3.12.15 for the missing-server_hostname case. This fixture always supplies the name, so that change is outside the tested condition.
If hostname failure needs independent qualification, qualify hostname rejection separately. That is a different experiment; introducing wrong-name peers here would weaken the issuer-specific negative controls rather than strengthen them.
Capture the factory baseline before changing trust
A fresh SSLContext(ssl.PROTOCOL_TLS_CLIENT) should not be casually described as having an empty store. The supplied preparation work initially made that assumption and stopped before evaluating the ten cases. Inspection showed a runtime-provided certificate entry already visible through get_ca_certs(binary_form=True). The final fixture was corrected to capture that entry as the baseline and then reason in relative changes.
Python documents get_ca_certs(binary_form=True) as returning loaded CA certificates as DER. It also notes an important boundary: certificates available through capath are not necessarily listed until requested and loaded during verification. The current experiment does not use capath, but the documentation is why get_ca_certs() should not be advertised as a universal inventory of every lazy or external trust source.
The reference preparation baseline had one certificate, SHA-256:
86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6bThe fixture asserts that neither generated owned root collides with that baseline identity. It then derives expected store state as “captured baseline plus the declared owned roots.” This is more defensible than deleting the unexpected entry to obtain aesthetically simple counts.
The distinction between DER identity and full trust semantics also matters. OpenSSL’s X509_STORE can hold certificate objects used in path construction and validation, and trusted X.509 objects may carry auxiliary trust information not encoded in the ordinary certificate bytes themselves. A SHA-256 fingerprint is therefore a strong identifier for the certificate’s DER representation, but it is not a complete encoding of every auxiliary trust or rejection property that could affect a store.
That is why the experiment does not conclude, “the complete trust policy is identical because the fingerprints match.” It concludes that the measured certificate identities and settings are preserved while the owned A/B behavior changes as declared.
Treat counts as supporting evidence, not identity
The preparation count progression was 1→2→3→2 for x509 and x509_ca, with crl remaining zero. Another runtime with an empty visible baseline could legitimately produce 0→1→2→1.
Counts can expose a setup anomaly, but they cannot tell a reviewer whether the extra certificate is root A, root B or something else. cert_store_stats() is documented as a quantity summary, while get_ca_certs(binary_form=True) exposes certificate bytes.
Accordingly, the fixture checks both. It also performs issuer-specific handshakes. A matching count without matching DER identity is insufficient; matching DER identity without the required negative and positive peer outcomes is also insufficient.
Run the complete owned TLS laboratory
The following is the supplied executed source, reproduced once. It constructs two disposable ECDSA roots, issues one same-name server leaf from each, loads roots through one explicit cafile, and drives ten sequential TLS 1.3 controls through memory BIOs. Cryptography’s X.509 API provides CertificateBuilder, validity inputs, extensions, signing and certificate fingerprints; its stable documentation consulted for this batch displayed 50.0.2. Python ssl, not Cryptography, performs the TLS peer verification in this fixture.
"""Owned in-memory TLS controls for replacing an explicitly loaded CA bundle."""
import hashlib, json, platform, ssl, sys, tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
import cryptography
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
HOST = 'service.lab.test'
PEM = serialization.Encoding.PEM
ROWS = []
def require(ok, label):
if not ok:
raise AssertionError(label)
def digest(data):
return hashlib.sha256(data).hexdigest()
def certificate(name, key, issuer, signer, serial, ca, now):
builder = (x509.CertificateBuilder().subject_name(name).issuer_name(issuer)
.public_key(key.public_key()).serial_number(serial)
.not_valid_before(now - timedelta(minutes=5))
.not_valid_after(now + timedelta(days=1))
.add_extension(x509.BasicConstraints(ca=ca, path_length=0 if ca else None), True)
.add_extension(x509.KeyUsage(not ca, False, False, False, False,
ca, ca, False, False), True)
.add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), False)
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(signer.public_key()), False))
if not ca:
builder = (builder.add_extension(x509.SubjectAlternativeName([x509.DNSName(HOST)]), False)
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), False))
return builder.sign(signer, hashes.SHA256())
def context(server=False):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER if server else ssl.PROTOCOL_TLS_CLIENT)
ctx.minimum_version = ctx.maximum_version = ssl.TLSVersion.TLSv1_3
return ctx
def handshake(client_ctx, server_ctx, expected_leaf):
ci, co, si, so = (ssl.MemoryBIO() for in range(4))
peers = [clientctx.wrap_bio(ci, co, server_hostname=HOST),
server_ctx.wrap_bio(si, so, server_side=True)]
done = [False, False]
for in range(64):
for index, peer in enumerate(peers):
if not done[index]:
try:
peer.dohandshake()
done[index] = True
except ssl.SSLWantReadError:
pass
except ssl.SSLCertVerificationError as error:
require(index == 0, 'Only client certificate rejection is expected')
return {'accepted': False, 'verify_code': error.verify_code,
'verify_message': error.verify_message, 'peer_sha256': None,
'protocol': None, 'session_reused': None}
outgoing, incoming = (co, si) if index == 0 else (so, ci)
if outgoing.pending:
incoming.write(outgoing.read())
if all(done):
peer_hash = digest(peers[0].getpeercert(binary_form=True))
require(peer_hash == expected_leaf, 'Presented leaf identity')
require(not peers[0].session_reused, 'Fresh full handshake')
return {'accepted': True, 'verify_code': None, 'verify_message': None,
'peer_sha256': peer_hash, 'protocol': peers[0].version(),
'session_reused': peers[0].session_reused}
raise RuntimeError('HOLD: in-memory handshake did not complete within the step budget')
def run():
now = datetime.now(timezone.utc)
roots, leaves, servers = {}, {}, {}
with tempfile.TemporaryDirectory(prefix='ssl-ca-replacement-') as owned:
directory = Path(owned)
for number, label in enumerate(('A', 'B'), 1):
root_key, leaf_key = (ec.generate_private_key(ec.SECP256R1()) for in range(2))
rootname = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'Owned root ' + label)])
leaf_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, HOST)])
roots[label] = certificate(root_name, root_key, root_name, root_key, number, True, now)
leaves[label] = certificate(leaf_name, leaf_key, root_name, root_key, 100 + number, False, now)
certfile, keyfile = directory / (label + '.pem'), directory / (label + '.key')
certfile.write_bytes(leaves[label].public_bytes(PEM))
with keyfile.open('xb') as stream:
keyfile.chmod(0o600)
stream.write(leaf_key.private_bytes(PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()))
servers[label] = context(server=True)
servers[label].load_cert_chain(certfile, keyfile)
root_hash = {label: cert.fingerprint(hashes.SHA256()).hex() for label, cert in roots.items()}
leaf_hash = {label: cert.fingerprint(hashes.SHA256()).hex() for label, cert in leaves.items()}
require(root_hash['A'] != root_hash['B'], 'Distinct trust anchors')
bundle = directory / 'current-ca.pem'
bundle.write_bytes(roots['A'].public_bytes(PEM))
reused = context()
baseline = {digest(der) for der in reused.get_ca_certs(binary_form=True)}
baseline_stats = reused.cert_store_stats()
require(not baseline.intersection(root_hash.values()), 'Owned roots absent from baseline')
def check(case, stage, ctx, disk_root, loaded, server, expected):
require(ctx.verify_mode == ssl.CERT_REQUIRED and ctx.check_hostname, 'Verification admission')
require(bundle.read_bytes() == roots[disk_root].public_bytes(PEM), 'Declared bundle bytes')
actual_roots = sorted(digest(der) for der in ctx.get_ca_certs(binary_form=True))
expected_roots = sorted(baseline.union(root_hash[label] for label in loaded))
require(actual_roots == expected_roots, 'Exact loaded root identities')
stats = ctx.cert_store_stats()
expected_stats = {key: value + (len(loaded) if key != 'crl' else 0)
for key, value in baseline_stats.items()}
require(stats == expected_stats, 'Store counts')
result = handshake(ctx, servers[server], leaf_hash[server])
row = {'case': case, 'stage': stage, 'bundle_root': disk_root,
'bundle_sha256': digest(bundle.read_bytes()), 'loaded_owned_roots': loaded,
'store_certificate_sha256': actual_roots, 'store_stats': stats,
'server_issuer': server, 'expected_accepted': expected, result}
ROWS.append(row)
require(result['accepted'] is expected, case + ' acceptance')
if not expected:
require(result['verify_code'] == 20, case + ' expected issuer rejection')
row['status'] = 'PASS'
check('T01', 'factory_baseline', reused, 'A', [], 'A', False)
check('T02', 'factory_baseline', reused, 'A', [], 'B', False)
reused.load_verify_locations(cafile=str(bundle))
check('T03', 'loaded_A', reused, 'A', ['A'], 'A', True)
check('T04', 'loaded_A', reused, 'A', ['A'], 'B', False)
bundle.write_bytes(roots['B'].public_bytes(PEM))
check('T05', 'disk_B_no_reload', reused, 'B', ['A'], 'A', True)
check('T06', 'disk_B_no_reload', reused, 'B', ['A'], 'B', False)
reused.load_verify_locations(cafile=str(bundle))
check('T07', 'reloaded_B_into_reused_context', reused, 'B', ['A', 'B'], 'A', True)
check('T08', 'reloaded_B_into_reused_context', reused, 'B', ['A', 'B'], 'B', True)
fresh = context()
require({digest(der) for der in fresh.get_ca_certs(binary_form=True)} == baseline,
'Replacement retains the factory baseline')
fresh.load_verify_locations(cafile=str(bundle))
check('T09', 'fresh_B_context', fresh, 'B', ['B'], 'A', False)
check('T10', 'fresh_B_context', fresh, 'B', ['B'], 'B', True)
require(reused is not fresh, 'Replacement context identity')
inventory = {'root_sha256': root_hash, 'leaf_sha256': leaf_hash,
'baseline_certificate_sha256': sorted(baseline),
'baseline_store_stats': baseline_stats,
'hostname': HOST, 'verify_flags': int(fresh.verify_flags),
'valid_from_utc': roots['A'].not_valid_before_utc.isoformat(),
'valid_until_utc': roots['A'].not_valid_after_utc.isoformat()}
require(not directory.exists(), 'Owned directory removed')
require([r['case'] for r in ROWS] == ['T%02d' % i for i in range(1, 11)], 'Case inventory')
return {inventory, 'owned_directory_removed': True}
if name == '__main__':
report = {'started_utc': datetime.now(timezone.utc).isoformat(),
'python': platform.python_version(), 'platform': platform.platform(),
'python_ssl': ssl.OPENSSL_VERSION, 'cryptography': cryptography.__version__,
'cryptography_backend': default_backend().openssl_version_text(),
'source_sha256': digest(Path(__file__).read_bytes()), 'cases': ROWS}
try:
report.update(run(), status='PASS_10_CASES')
code = 0
except Exception as error:
report.update(status='HOLD', error=type(error).__name__, detail=str(error))
code = 1
report['finished_utc'] = datetime.now(timezone.utc).isoformat()
print(json.dumps(report, indent=2))
sys.exit(code)Save the canonical supplied source as security_ssl_fixture.py in a writable disposable directory. A Python 3.12 environment with Cryptography 50.0.2 available is the reference preparation condition. For a separate environment, the optional dependency setup is:
python -m pip install 'cryptography==50.0.2'
python security_ssl_fixture.py > security_ssl_results.jsonInstalling Cryptography does not select or replace the OpenSSL library linked to Python’s ssl module. Record both runtime strings emitted by the program.
The supplied preparation evidence is: start 2026-10-30T07:39:14.267862+00:00; finish 2026-10-30T07:39:14.291081+00:00; exit 0; PASS_10_CASES; CPython 3.12.14; Linux-6.18.57-x86_64-with-glibc2.39; Python ssl backend OpenSSL 3.5.8 25 Aug 2026; Cryptography 50.0.2 generation backend OpenSSL 4.0.3 29 Sep 2026; verify_flags 32768; accepted sessions TLSv1.3 with session_reused=false. The supplied source SHA-256 is 040ca514084086c3fe3f6a0e3a0e9a0ce74fdd58eaa55fe3bd2053a212de49c6.
A reproduction should require exit zero, complete JSON, PASS_10_CASES, the canonical source hash and exactly T01 through T10 before interpreting the result. Random key generation means new certificate DER bytes, fingerprints and validity timestamps are expected on later runs. Cryptography documents the builder inputs used here (subject, issuer, public key, validity, extensions and signature), and its fingerprint API computes the digest over a certificate with the requested hash algorithm.
The complete supplied reference ledger is:
Case | Stage | Disk root | Loaded owned roots | Peer issuer | Expected / observed | Reference count |
T01 | Factory baseline | A | none | A | reject / reject, code 20 | 1 / 1 |
T02 | Factory baseline | A | none | B | reject / reject, code 20 | 1 / 1 |
T03 | Load A | A | A | A | accept / accept | 2 / 2 |
T04 | Load A | A | A | B | reject / reject, code 20 | 2 / 2 |
T05 | Write B, no reload | B | A | A | accept / accept | 2 / 2 |
T06 | Write B, no reload | B | A | B | reject / reject, code 20 | 2 / 2 |
T07 | Reload B into reused context | B | A+B | A | accept / accept | 3 / 3 |
T08 | Reload B into reused context | B | A+B | B | accept / accept | 3 / 3 |
T09 | Fresh context, load B | B | B | A | reject / reject, code 20 | 2 / 2 |
T10 | Fresh context, load B | B | B | B | accept / accept | 2 / 2 |
The cases are an independent oracle, not labels inferred from whichever connection succeeds. Rejected cases must be client-side SSLCertVerificationError with verification code 20 in this owned fixture; arbitrary setup failures do not satisfy the negative controls. Accepted cases also verify the presented leaf’s DER fingerprint, so a handshake to an unintended synthetic peer cannot be counted as evidence.
Show why editing the bundle leaves the context unchanged
T03 and T04 establish the initial loaded state. With root A in the file and after reused.load_verify_locations(cafile=...), the context inventory equals the captured baseline plus A. The A-issued peer is accepted and the B-issued peer is rejected.
Then the program overwrites the bundle with B but does not call load_verify_locations() again. T05 and T06 show the important contrast: the disk now contains B, yet the context still contains A, accepts A and rejects B.
That is consistent with OpenSSL’s documented CA-file semantics: the file is processed when the verify-location loading function executes. The documentation does not describe SSL_CTX_load_verify_locations() as establishing a permanent file watcher or a caller-defined “replacement relationship” with future bytes at the same path.
The observation is therefore narrower than “OpenSSL always caches everything.” It is: in the supplied Python 3.12.14/OpenSSL 3.5.8 run, replacing the selected file’s bytes did not retroactively rewrite the certificate objects already loaded in the existing client context.
Under the declared A-retired/B-required policy, this stage is REPAIR. The intended configuration file changed, but the object performing verification did not acquire the intended trust state.
Pair the disk digest with the context identity
A disk digest is still valuable: it proves which PEM bytes the test believes are selected at that stage. It simply answers a different question from the context inventory.
The review record should therefore pair the bundle digest with the identity of the context under test and its loaded certificate fingerprints. T05’s useful diagnostic is precisely the mismatch: B on disk, baseline+A in reused.
Nothing in this experiment establishes filesystem polling, atomic publication or a safe generic hot-reload mechanism, so none should be inferred.
Prove that a reload can create an overlap store
After T05/T06, the program calls reused.load_verify_locations() again while the file contains B. T07 then verifies the A-issued peer and T08 verifies the B-issued peer. Both succeed. The inventory assertion expects baseline+A+B, and the reference counts increase from 2 to 3.
Python describes load_verify_locations() as loading CA certificates into the context and expressly notes that adding new certificates to the internal trust store remains supported even after the context has been used. OpenSSL’s file lookup implementation describes certificates from a file being loaded into the associated store’s in-memory cache, and its X509_STORE documentation describes store operations in terms of adding/loading trusted certificate objects.
Those documents support the mechanism; the supplied run supplies the A+B outcome for this environment. A successful return from the loading call means the load operation succeeded. It does not mean “all prior CA objects were removed,” because that is a caller policy requirement not documented as the semantics of this operation.
T08 alone would therefore be a weak rotation test. It proves B now works but says nothing about A. T07 is the control that exposes the retirement failure.
If an A+B overlap were explicitly authorized, that could be assessed under a different policy. For the current policy, the reused context remains REPAIR because A is still accepted.
Construct a replacement through the same factory
T09/T10 take a different path. Rather than attempting to subtract trust from the reused object, the fixture constructs fresh = context(), verifies that its visible factory baseline equals the earlier captured baseline, and loads B from the current file.
The result is the required asymmetric pair: T09 rejects the A-issued leaf with verification code 20; T10 accepts the B-issued leaf. The code also asserts reused is not fresh, preventing the review from conflating the replacement candidate with the previous object.
This is the first stage satisfying the declared owned-root retirement policy. It is important to phrase the approval narrowly: the fresh candidate passes the A-reject/B-accept requirement under the recorded factory, visible baseline, hostname, TLS 1.3 restriction and verification settings. It is not evidence that the process globally trusts “B only,” because the retained baseline certificate remains present. Nor is it evidence about other trust-loading mechanisms that the fixture does not use.
The successful handshakes also report TLSv1.3 and session_reused=false. Python’s SSLObject exposes both protocol version and session-reuse state, allowing those laboratory controls to be recorded without a socket.
Recreate policy deliberately before testing the candidate
A new SSLContext should not be treated as magically equivalent to the application’s approved client policy. Construction has to come through the same reviewed factory or through another configuration path whose settings are explicitly reconciled.
This fixture compares the properties it declares: client protocol role, TLS 1.3 minimum and maximum, CERT_REQUIRED, hostname checking, verify_flags, visible baseline fingerprints and owned CA additions. It does not claim to implement a universal SSLContext clone, and Python does not document such a general clone operation here.
A production review therefore approves a named replacement candidate with recorded properties, not the generic idea that “fresh contexts are safer.”
Reconcile the complete acceptance matrix
The ten cases are most useful when read as five paired stages.
T01/T02 establish that neither owned issuer is accidentally trusted by the captured factory baseline. Without those controls, later positive results could be ambiguous because an owned root might already have entered through some unrelated setup.
T03/T04 prove the first intended state after loading A: A succeeds and B fails. That shows the laboratory can distinguish the two independent roots even though both leaves use the same server name.
T05/T06 isolate file replacement from context replacement. The file contains B, but the client’s behavior remains A-positive/B-negative. That is the concrete evidence against a disk-only assumption.
T07/T08 isolate additive reload behavior in the reused object. B becomes trusted, but A remains trusted. A positive B handshake cannot establish retirement; the paired A control is indispensable.
T09/T10 qualify the replacement candidate. Rejecting A is necessary, but still not sufficient. A checker that rejected both A and B could satisfy a careless “old root no longer works” test while leaving the intended service unreachable. The B-positive control demonstrates that the candidate retains the newly required path.
The rejection reason matters as well. The supplied run observed client-side code 20, “unable to get local issuer certificate,” for every expected issuer-negative control. That is consistent with the fixture’s deliberately direct, independently signed owned chains. A hostname failure, expired certificate or generic TLS setup exception would not prove the same condition.
Finally, PASS_10_CASES describes the complete fixture executing according to its oracle. Deployment review must still identify which object is being proposed. In this ledger, the reused context diagnoses two unsuitable retirement strategies while the fresh context is the bounded ACCEPT candidate.
Decide whether the evidence supports ACCEPT, REPAIR or HOLD
A review decision should be attached to evidence and ownership, not inferred from green console output.
Condition | Required evidence | Decision | Primary owner |
Fresh candidate retains recorded baseline, rejects A with the expected issuer reason, accepts B, matches declared settings and has complete provenance | T09/T10 plus baseline, source/runtime identity and settings | ACCEPT | Security reviewer with implementation owner |
File contains B but active candidate still contains/accepts A | T05/T06-type evidence | REPAIR | Implementation owner |
Reload adds B but A remains accepted under an A-retirement policy | T07/T08-type evidence | REPAIR | Implementation owner, policy confirmed by security owner |
A+B overlap is explicitly authorized | Written overlap policy plus controls demonstrating exactly that state | Evaluate against that separate policy, not this retirement oracle | Security owner |
Baseline unexpectedly changes, owned root appears in baseline, source/case inventory is incomplete, settings differ materially, or negative cases fail for another reason | Unreconciled evidence | HOLD | Reviewer |
B is rejected by the candidate | T10 or equivalent positive control fails | HOLD | Implementation and security owners |
Result has been “fixed” by editing expected values after an unexpected run | Oracle no longer independent | HOLD | Reviewer |
For the supplied preparation evidence, the review interpretation is straightforward. The disk-only assumption shown by T05/T06 requires REPAIR. Reusing the context and reloading B into it produces an unintended A+B overlap under this policy, also REPAIR. The distinct fresh context demonstrated by T09/T10 is eligible for bounded ACCEPT, subject to the recorded baseline and settings.
The initial baseline surprise illustrates when HOLD is appropriate. The correct response was not to strip the runtime-provided certificate until the count became zero. The preparation was stopped, the baseline was investigated, and the final fixture was rewritten to measure relative state while preserving it. That makes the unexplained condition visible rather than laundering it into a convenient output.
Require the expected failure, not merely any failure
Negative controls are informative only when the failure corresponds to the policy dimension being tested.
Here, the server name is valid, the certificates are freshly generated inside their validity interval, TLS 1.3 is deliberately required, and the two roots sign independent leaves. Under those controls, code 20 supports the intended conclusion that the client cannot build the A or B leaf to a locally available issuer in the relevant negative case.
An expired fixture, unsupported TLS 1.3 backend, malformed key, missing server_hostname, iteration-budget failure or arbitrary exception is not an acceptable substitute. Python documents SSLCertVerificationError as the certificate-verification exception type; the fixture goes further and asserts the expected verification code for its controlled negative cases.
Likewise, never edit expected_accepted or the required code simply to preserve PASS_10_CASES. That would erase the independent policy oracle. The B-positive T10 control is what prevents a broken “reject everything” client from being approved.
Recover without silently restoring the retired root
When a rotation attempt produces the wrong state, preserve the evidence first. Record the source hash, runtime versions, bundle digest, context identity, visible certificate inventory, settings and all case results before changing the candidate again. The difference between “B is on disk” and “the object still accepts A” is valuable incident evidence.
Next, identify the exact client context intended for use. If the application still references the reused A-bearing object, changing another file or constructing an unreferenced context does not resolve the deployment decision.
Then reconstruct a candidate through the approved factory or another explicitly reviewed construction path. Load B into that candidate and rerun issuer-specific negative and positive controls. If A still succeeds, B fails, the visible baseline cannot be reconciled, or verification settings no longer match the declared policy, the publication decision remains HOLD.
Do not silently “recover” by falling back to A merely because A is known to connect. Once the security owner has declared A retired, especially if retirement is linked to a suspected compromise, restoring it changes the policy. That requires explicit authorization rather than being treated as an automatic availability maneuver.
The fixture also cannot undo history. A replacement context governs new SSLObject instances created from it. This experiment supplies no evidence about connections completed earlier, and it does not claim that changing a CA file or Python object retroactively invalidates an established TLS session.
Investigation of previously accepted connections belongs in incident handling outside this laboratory. The article’s decision boundary is narrower: identify the candidate that will be used for future verification and prove its controlled A/B behavior before approval.
Separate candidate qualification from application rollout
Qualifying fresh is not the same action as publishing it to an application.
The fixture constructs a distinct context, tests it and records its identity relationship to reused. It does not swap an application reference, locate every consumer, drain a connection pool, alter a worker process or define when a service should instantiate replacement contexts. Those are application-design and rollout questions.
A service review therefore still needs to identify where the relevant SSLContext is created, which components retain references to it, how a replacement becomes the context used by new client connections, and which separately governed lifecycle applies to existing sessions or pools. None of those implementation steps is exercised here.
That ownership distinction is similar to client trust requirements during a collector migration: a replacement component being healthy is not by itself proof that the operational behavior relied upon by consumers has been preserved. The linked article concerns a different technology and should not be imported as Python or TLS guidance; its relevant editorial principle is that the owner of the consuming path must define the acceptance evidence.
The file publication mechanism is also deliberately out of scope. Path.write_bytes() is used serially because this is an owned disposable laboratory. It demonstrates neither atomic replacement nor safe concurrent readers. Do not turn those writes into a production bundle-publication recommendation.
Likewise, the test creates sequential fresh SSLObjects and asserts no successful session is reused. Live pools, resumed sessions, concurrent reconfiguration and established connections require separate controls.
Turn the test into a bounded review requirement
A useful security review record should make later reconstruction possible without pretending to be tamper-proof.
The record should contain one declared policy statement; the canonical fixture source hash; actual Python, platform, Python-ssl OpenSSL and Cryptography/backend versions; verify_flags; hostname and TLS restriction; baseline certificate fingerprints and store statistics; generated owned root and leaf fingerprints for that run; bundle digest by stage; T01 through T10 outcomes including verification codes; successful peer fingerprints and protocol/session-reuse values; and the reviewer’s final ACCEPT, REPAIR or HOLD disposition tied to a named candidate.
That kind of evidence can be integrated with security checks in delivery workflows, but this article does not require adopting the SAST, DAST or dependency-scanning techniques discussed there. The relevant connection is narrower: repeatable security checks become more reviewable when their criteria and failure semantics are recorded rather than inferred from a generic successful job status.
Normal temporary-directory cleanup is evidence of expected control flow, not evidence of secure erasure. The generated root private keys remain Python objects in process memory until normal lifecycle cleanup; the leaf private keys are written in the owned temporary directory with mode 0600; and the reference execution reports that the directory no longer exists after the context manager exits. None of that proves memory sanitization, crash cleanup or tamper resistance.
The preparation run should also remain distinguishable from later reproductions. Do not overwrite the reference provenance with a new run’s certificate hashes or timestamps.
Record differences instead of normalizing them away
Random regeneration is expected to change keys, certificate DER, root and leaf SHA-256 values, and the one-day validity timestamps. Those changes are consequences of constructing fresh disposable credentials, not reasons to edit the result until it resembles the reference.
Meaningful differences include a changed fixture source hash, different case inventory, different client flags, a new visible factory baseline, an owned certificate unexpectedly appearing in that baseline, a different acceptance matrix, an unexpected verification reason or lack of TLS 1.3 support.
Store counts should always be interpreted relative to the actual measured baseline. A 0→1→2→1 sequence can be consistent with the same owned-root logic as the preparation’s 1→2→3→2 sequence. The identities and controlled handshakes decide whether the policy is met.
Assign ownership for the remaining trust decisions
The cleanest review separates four responsibilities.
The security owner declares whether A is retired, whether any A+B overlap is authorized, and what baseline policy applies. Without that declaration, the same technical state can be misclassified: A+B is a defect under immediate retirement but may be intentional under a documented overlap window.
The implementation owner constructs the candidate, records which SSLContext object is intended for use and preserves the approved verification settings while loading the new CA material.
The reviewer reconciles the file, certificate inventory and handshake evidence. That reviewer should reject claims such as “B works, so the rotation passed” when the A-negative control is missing.
The service owner controls the separately governed rollout: publishing the candidate reference, identifying consumers and deciding what to do about existing sessions, workers and connection pools.
This ownership model aligns with the general concern in Refonte Learning’s article on API security evidence and ownership: controls are useful only when their evidence and operating responsibilities are explicit. The linked page covers broader API and observability subjects; it is not technical authority for this Python trust-store behavior.
The unresolved scope should remain attached to the approval. This laboratory does not qualify intermediates, cross-signs, alternate path building, CRL or OCSP completeness, capath, URI stores, default-loader behavior, mTLS, resumed sessions, concurrent context mutation, connection pools or production PKI architecture.
The final review artifact is therefore a candidate-specific statement: candidate X, built through factory Y, with recorded baseline Z and settings S, rejects owned A for the expected issuer reason and accepts owned B under the ten-case fixture. The security reviewer can ACCEPT that bounded claim, require REPAIR of an unsuitable strategy, or HOLD when the evidence does not support a decision.
Build the cloud-security foundations behind the decision
Trust-store changes sit at the intersection of encryption controls, incident response and monitoring discipline: define a security state, collect evidence from the actual enforcement object, exercise both negative and positive controls, and record a reviewable disposition.
Refonte Learning’s Cloud Security Engineer Essentials page lists data encryption, threat detection and mitigation, incident response planning, cloud monitoring and logging, IAM, secure SDLC, risk management and governance among its competencies. The page states a three-month duration and 10 to 12 hours per week, and it describes hands-on projects and an educational mentor, Dr. Christine Baker.
The same page says basic networking and cloud-computing knowledge is recommended and separately lists working toward a bachelor’s or higher-level degree as an obligatory admission prerequisite. Successful completion is described as offering a Training Certificate and Certificate of Internship.
The operational lesson stays narrower than any training claim: record the context you intend to publish, preserve its approved baseline and settings, and qualify that specific candidate against an independently declared A-reject/B-accept policy before calling a private-root retirement complete.
