A legacy encrypted-record consumer runs openssl enc -aes-256-cbc -d, receives exit status 0, and now has bytes on stdout. The dangerous operational shortcut is to treat those three facts as one proposition: decryption succeeded, therefore the plaintext is safe to consume. They are not one proposition.
A release gate needs to distinguish at least three evidence layers. First is the process result: did the command report success or failure? Second is recovered content: in a laboratory where the original bytes are independently known, did the output equal those bytes or a precisely predicted variant? Third is authentication: does the cryptographic mechanism establish that the supplied ciphertext and required context are authentic relative to the key and authenticated inputs?
This playbook qualifies those distinctions in an owned Linux fixture, using the recorded OpenSSL 3.0.13 command-line executable for CBC and cryptography 50.0.2's one-shot AESGCM.decrypt interface, whose observed backend reported OpenSSL 4.0.3. Those are commissioning measurements, not claims about the newest releases.
The experiment produces the operational answer: REJECT failed decryption or authentication; HOLD plaintext from successful but unauthenticated CBC; ACCEPT the qualified authenticated laboratory record; and HOLD an implementation whose evidence is incomplete or inconsistent. Production admission still needs its own application, authorization and provenance contract. The laboratory's known plaintext is an independent oracle, not something available for arbitrary legacy encrypted files.
Decide what counts as admissible plaintext
The first review step is to stop using “decrypted” as a synonym for “admissible.”
Confidentiality answers whether someone lacking the required secret can obtain the protected plaintext under the scheme's assumptions. NIST SP 800-38A classifies CBC among its confidentiality modes; that classification does not itself add a message-authentication result.
Known-byte equality is different. In this laboratory, the author fixes a 64-byte plaintext before encryption. The harness can therefore compare recovered bytes with an independent expected value. That is a powerful test oracle, but it exists because the test author already knows the answer. It is not an OpenSSL CBC decryption authentication feature, and it does not solve legacy encrypted file authenticity when the original trusted bytes are unknown.
Authentication is different again. RFC 5116 describes authenticated decryption in terms of a key, nonce, associated data and ciphertext, with the result being plaintext or failure according to those inputs. It explicitly leaves access control and anti-replay decisions outside that cryptographic interface.
The same separation matters whenever an application consumes security-sensitive artifacts. Refonte Learning's broader discussion of security controls around ML artifacts also treats integrity and provenance as concerns distinct from merely possessing an artifact; this playbook narrows that principle to one encrypted-record admission decision.
The gate therefore has four usable outcomes:
Evidence state | Decision |
Decryption or authentication operation fails | REJECT |
CBC returns bytes successfully but supplies no authentication evidence | HOLD_NO_AUTHENTICATION |
Qualified AEAD positive returns and independent lab oracle agrees | ACCEPT_AUTHENTICATED_LAB_RECORD |
Runtime, implementation behavior or evidence is incomplete or unexpected | HOLD_IMPLEMENTATION_UNQUALIFIED |
Parseability, a plausible filename, public-looking metadata or a digest delivered beside untrusted data does not close the authentication gap. Those may be useful application inputs, but none becomes trustworthy merely because the decrypted bytes look reasonable.
Freeze the runtime and write down the known bytes
The commissioning run is deliberately version-bound. Its measured environment was Python 3.12.14 on Linux 6.18.55 x86_64/glibc 2.39. /usr/bin/openssl reported OpenSSL 3.0.13, 30 Jan 2024; the Python package was cryptography 50.0.2, whose default_backend().openssl_version_text() reported OpenSSL 4.0.3, 29 Sep 2026.
Those OpenSSL identities matter. CBC is exercised through the command-line executable, while AES-GCM is exercised through the Python package's backend. Comparing them is useful for contrasting interfaces and admission semantics, but they are not two independent cryptographic-library validations. The dates are captured runtime strings, not independently asserted release dates.
The CLI executable SHA-256 recorded for the commissioning build was:
204951390b4f82d39f98b0dd0fa54502f27256c8d2fd08b55a5e3781408ae83bThe executed source SHA-256 was:
54cad0ead78a9db5060a691bc9d3d56ceb0f3ea69783440fa59e49dfcddad107The authored input is fixed before encryption:
0123456789ABCDEFfedcba9876543210owned-lab-recordno-real-secrets!That is exactly 64 bytes: four AES-sized 16-byte data blocks. The fixed AAD is:
owned-lab-record;format=1;object=fixtureFresh synthetic CBC and AES-GCM keys are generated for the disposable run. No real encrypted file, production key or external service is an input. The CLI receives an empty lab-owned OPENSSL_CONF and explicitly requests the default provider. The JSON report records runtime provenance, the authored plaintext oracle, process outcomes, output comparisons and release events.
A missing OpenSSL binary, unavailable provider, unsupported algorithm, timeout or incompatible dependency is therefore a qualification blocker, not a passed cryptographic negative control.
Read the CBC contract before trusting the exit code
At the October 27, 2026 research cutoff, the versioned OpenSSL 3.0 enc manual documented hexadecimal -K and -iv inputs and normal PKCS#7 padding. It describes that padding check only as a rudimentary integrity/password check and notes that random data can pass it with probability better than 1 in 256. That is the relevant OpenSSL enc integrity check boundary: padding acceptance is not message authentication.
The manual is equally explicit that enc does not support authenticated modes such as GCM or CCM. Its rationale includes a streaming problem: output may already have been passed downstream before a trailing authentication decision could fail, making rollback impossible for a pipeline consumer. The documentation points bulk-encryption users toward CMS, but that pointer does not establish that an arbitrary CMS invocation supplies the authentication contract this experiment needs.
The small amount of CBC reasoning required comes from NIST SP 800-38A. For decryption it defines:

and, for later blocks,

NIST calls CBC a confidentiality mode and also says the integrity of the IV should be protected.
Those equations let the harness predict specific changed-plaintext relationships independently of the decryptor. They are not an attack service and do not reveal the key.
The discipline resembles qualifying an OpenSSL hostname-verification probe: a useful process gate must be shown to reject the invalid condition that matters, rather than merely print diagnostics or return some bytes. The TLS article's identity and certificate concerns remain entirely outside this CBC workflow.
Neither increasing the AES key size nor turning padding off supplies authentication. A larger encryption key changes key strength; -nopad removes the already-limited padding check. Neither creates evidence that the ciphertext or IV is the one the application intended to trust.
Run the owned ten-case fixture
Save the following commissioning fixture as security_cbc_fixture.py. It requires Python with cryptography and an available OpenSSL 3 command-line executable; provisioning those dependencies is separate from the experiment.
Run it as:
python security_cbc_fixture.py > security_cbc_results.jsonThe fixture keeps subprocess stdout and stderr inside the owned harness, uses a five-second subprocess timeout, captures openssl version -a, records failures in JSON and removes its temporary directory after the run. A negative control counts as passed only when its explicit oracle is satisfied; a setup exception does not masquerade as a cryptographic rejection.
#!/usr/bin/env python3
"""Owned, in-memory CBC/authentication controls; never a production file format."""
import hashlib
import json
import os
from pathlib import Path
import platform
import shutil
import subprocess
import sys
import tempfile
import cryptography
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
PLAIN = (b"0123456789ABCDEF" b"fedcba9876543210"
b"owned-lab-record" b"no-real-secrets!")
AAD = b"owned-lab-record;format=1;object=fixture"
def require(condition, message):
if not condition:
raise RuntimeError(message)
def digest(data):
return hashlib.sha256(data).hexdigest()
def flip(data, index):
out = bytearray(data)
out[index] ^= 1
return bytes(out)
def run_lab(report):
require(len(PLAIN) == 64, "The authored fixture must occupy four AES blocks")
openssl = shutil.which("openssl")
require(openssl is not None, "openssl is required")
openssl = str(Path(openssl).resolve())
report["environment"] = {
"python": sys.version,
"platform": platform.platform(),
"openssl_path": openssl,
"openssl_binary_sha256": digest(Path(openssl).read_bytes()),
"cryptography_version": cryptography.__version__,
"cryptography_backend": default_backend().openssl_version_text(),
"source_sha256": digest(Path(__file__).read_bytes()),
"configuration": "empty lab-owned OPENSSL_CONF; explicit default provider",
}
report["oracle"] = {
"authored_plaintext_hex": PLAIN.hex(),
"authored_plaintext_sha256": digest(PLAIN),
"plaintext_length": len(PLAIN),
"aad_ascii": AAD.decode("ascii"),
"rule": "Known authored bytes and CBC block equations, not a supplied digest",
}
with tempfile.TemporaryDirectory(prefix="cbc-auth-owned-") as tmp:
config = Path(tmp) / "openssl.cnf"
config.write_text("# Empty configuration for the owned fixture.\n")
env = dict(os.environ)
for name in ("OPENSSL_CONF", "OPENSSL_CONF_INCLUDE", "OPENSSL_MODULES",
"OPENSSL_ENGINES"):
env.pop(name, None)
env["OPENSSL_CONF"] = str(config)
env["LC_ALL"] = "C"
def command(args, data=b""):
return subprocess.run([openssl, *args], input=data, capture_output=True,
timeout=5, cwd=tmp, env=env, check=False)
version = command(["version", "-a"])
require(version.returncode == 0, "OpenSSL version capture failed")
report["environment"]["openssl_version_a"] = version.stdout.decode()
report["environment"]["openssl_version_stderr"] = version.stderr.decode()
key, iv = os.urandom(32), os.urandom(16)
def cbc(data, test_iv, decrypt):
return command(["enc", "-aes-256-cbc", "-d" if decrypt else "-e",
"-provider", "default", "-K", key.hex(),
"-iv", test_iv.hex()], data)
encrypted = cbc(PLAIN, iv, False)
require(encrypted.returncode == 0, "CBC encryption failed")
ciphertext = encrypted.stdout
require(len(ciphertext) == 80, "Expected 64 data bytes plus PKCS#7 block")
report["cbc_encryption"] = {
"returncode": encrypted.returncode,
"stderr": encrypted.stderr.decode(errors="replace"),
"ciphertext_length": len(ciphertext),
"ciphertext_sha256": digest(ciphertext),
"key_bytes": 32, "iv_bytes": 16,
"key_material_policy": "Fresh synthetic key in process arguments; not retained",
}
variants = [
("C01", "unchanged", ciphertext, iv, "baseline"),
("C02", "IV byte 0 XOR 1", ciphertext, flip(iv, 0), "iv"),
("C03", "ciphertext byte 0 XOR 1", flip(ciphertext, 0), iv, "block"),
("C04", "ciphertext byte 63 XOR 1", flip(ciphertext, 63), iv, "padding"),
]
for case_id, change, data, test_iv, kind in variants:
result = cbc(data, test_iv, True)
out = result.stdout
if kind == "baseline":
oracle = result.returncode == 0 and out == PLAIN
elif kind == "iv":
oracle = result.returncode == 0 and out == flip(PLAIN, 0)
elif kind == "block":
oracle = (result.returncode == 0 and len(out) == 64
and out[:16] != PLAIN[:16]
and out[16:] == flip(PLAIN, 16)[16:])
else:
# Padded block 5 ends in 0x10; changing C4[15] makes it 0x11.
oracle = result.returncode != 0
report["cases"].append({
"id": case_id, "family": "CBC", "change": change,
"process_returncode": result.returncode,
"stderr": result.stderr.decode(errors="replace"),
"output_length": len(out), "output_sha256": digest(out),
"equals_authored_plaintext": out == PLAIN,
"oracle_pass": oracle, "release_events_added": 0,
"decision": "HOLD_NO_AUTHENTICATION" if result.returncode == 0
else "REJECT_DECRYPTION_FAILURE",
})
require(oracle, f"CBC control {case_id} failed")
aead_key, nonce = AESGCM.generate_key(bit_length=256), os.urandom(12)
# Exactly one encryption under this fresh key/nonce, then decrypt controls.
sealed = AESGCM(aead_key).encrypt(nonce, PLAIN, AAD)
report["aead_encryption"] = {
"calls_under_key_nonce": 1, "key_bytes": 32, "nonce_bytes": 12,
"ciphertext_and_tag_length": len(sealed), "tag_bytes": 16,
"ciphertext_and_tag_sha256": digest(sealed),
}
controls = [
("G01", "unchanged", aead_key, nonce, sealed, AAD, True),
("G02", "ciphertext byte 0 XOR 1", aead_key, nonce, flip(sealed, 0), AAD, False),
("G03", "tag last byte XOR 1", aead_key, nonce, flip(sealed, -1), AAD, False),
("G04", "AAD byte 0 XOR 1", aead_key, nonce, sealed, flip(AAD, 0), False),
("G05", "nonce byte 0 XOR 1", aead_key, flip(nonce, 0), sealed, AAD, False),
("G06", "key byte 0 XOR 1", flip(aead_key, 0), nonce, sealed, AAD, False),
]
releases = report["release_events"]
for case_id, change, test_key, test_nonce, data, test_aad, valid in controls:
before = len(releases)
recovered = None
try:
recovered = AESGCM(test_key).decrypt(test_nonce, data, test_aad)
except InvalidTag:
status = "InvalidTag"
else:
status = "authenticated_return"
# This in-memory event is downstream of successful decrypt return.
releases.append({"case": case_id, "plaintext_sha256": digest(recovered)})
delta = len(releases) - before
oracle = ((status == "authenticated_return" and recovered == PLAIN and delta == 1)
if valid else (status == "InvalidTag" and recovered is None and delta == 0))
report["cases"].append({
"id": case_id, "family": "AESGCM", "change": change,
"status": status, "oracle_pass": oracle,
"release_events_added": delta,
"equals_authored_plaintext": recovered == PLAIN,
"decision": "ACCEPT_AUTHENTICATED_LAB_RECORD" if valid
else "REJECT_AUTHENTICATION_FAILURE",
})
require(oracle, f"AEAD control {case_id} failed")
require([r["case"] for r in releases] == ["G01"], "Unexpected release ledger")
report["owned_temporary_directory_removed"] = not Path(tmp).exists()
require(report["owned_temporary_directory_removed"], "Owned cleanup failed")
require(len(report["cases"]) == 10, "Case ledger incomplete")
report["all_controls_passed"] = True
if name == "__main__":
report = {"cases": [], "release_events": [], "all_controls_passed": False}
status = 0
try:
run_lab(report)
except Exception as exc:
report["failure"] = {"type": type(exc).__name__, "message": str(exc)}
status = 1
print(json.dumps(report, indent=2))
raise SystemExit(status)The supplied commissioning result was:
Case | Independent expectation | Observed commissioning result | Admission |
C01 | Exact authored 64 bytes | Exit 0; exact match | HOLD_NO_AUTHENTICATION |
C02 | IV change flips only plaintext byte 0 | Exit 0; exact predicted change | HOLD_NO_AUTHENTICATION |
C03 | C1 change alters P1 and flips byte 0 of P2; later suffix unchanged | Exit 0; oracle passed; 64 bytes | HOLD_NO_AUTHENTICATION |
C04 | Final padding becomes invalid | Exit 1; bad decrypt; 64 stdout bytes observed | REJECT_DECRYPTION_FAILURE |
G01 | Original AEAD inputs return authored bytes | Exact match; one release event | ACCEPT_AUTHENTICATED_LAB_RECORD |
G02 | Changed ciphertext fails | InvalidTag; zero events | REJECT_AUTHENTICATION_FAILURE |
G03 | Changed tag fails | InvalidTag; zero events | REJECT_AUTHENTICATION_FAILURE |
G04 | Changed AAD fails | InvalidTag; zero events | REJECT_AUTHENTICATION_FAILURE |
G05 | Changed nonce fails | InvalidTag; zero events | REJECT_AUTHENTICATION_FAILURE |
G06 | Changed key fails | InvalidTag; zero events | REJECT_AUTHENTICATION_FAILURE |
All ten supplied controls passed, and G01 was the only release event.
Keep laboratory key handling inside the experiment
The key handling here is intentionally narrow. CBC receives a fresh 32-byte synthetic key and 16-byte IV, and those values appear as subprocess arguments. That exposure is acceptable only within the stated disposable, lab-owned conditions; the command is not a production secret-handling recipe.
AES-GCM likewise gets a fresh 256-bit key and a fresh 12-byte nonce. No keys are written to the JSON evidence. The temporary directory contains only the owned configuration and process working context and is removed after the test.
That cleanup is not a secure-erasure claim, an operating-system sandbox attestation or a substitute for production key custody. Secure storage, injection, rotation, process isolation and key-lifecycle controls require their own engineering decisions. The fixture proves none of them.
Establish the unchanged CBC control
C01 is the positive CBC setup control. The ciphertext, key and IV are unchanged. The commissioning result is process exit 0, 64 output bytes and exact equality with the 64 bytes authored before encryption.
That positive is necessary. A harness that merely rejects every input would not be a qualified discriminator. Before interpreting any negative control, the reviewer should know that the normal path works under the frozen runtime.
The evidence fields remain separate:
Field | C01 evidence |
process_returncode | 0 |
output_length | 64 |
equals_authored_plaintext | true |
oracle_pass | true |
release_events_added | 0 |
decision | HOLD_NO_AUTHENTICATION |
The last row is the important one. Exact recovery of known test bytes validates this fixture's encryption/decryption wiring. It does not add authentication to openssl enc.
This distinction prevents a subtle review error: using the laboratory oracle as though it were part of the deployed encrypted-record format. Arbitrary legacy ciphertext will not arrive accompanied by an independently trusted copy of its expected plaintext. A recalculated SHA-256 of the recovered output can label those bytes, but unless a digest is itself bound to a trusted provenance mechanism, equality with that self-generated digest says nothing about historical authenticity.
C01 therefore succeeds cryptographically as a CBC decryption and still remains on HOLD for downstream admission.
Change the IV while keeping valid padding
C02 is the first decisive demonstration that CBC can return modified plaintext with valid padding.
The fixture changes exactly one input bit:
IV byte 0 ^= 1It does not alter the ciphertext, the AES key or any later CBC block. The commissioning result is still exit 0 and still 64 bytes of unpadded plaintext. But those bytes are not the authored plaintext: their first byte differs by the exact one-bit change predicted before decryption.
The padding remains valid because the IV participates only in recovery of the first plaintext block. The padding is in the fifth decrypted block, created because the original 64-byte record occupied exactly four data blocks. Changing the IV does not modify the ciphertext block relationships that recover that final padding block.
This is why an AES CBC IV integrity question cannot be answered by checking only the process code. NIST's CBC definition says the first plaintext block is formed by XORing the block-cipher inverse of C1 with the IV; NIST also explicitly says CBC IV integrity should be protected.
The resulting evidence is stronger than saying “tampering changed something.” The test predicts which plaintext bit must change, runs the decryptor, and checks exact equality with that independent expectation.
A visual inspection would be weaker. So would a hash copied from the same untrusted input bundle as the ciphertext. The fixture instead begins with known authored bytes and a documented mode equation.
Predict the first-byte change independently
For C02, hold K and C1 fixed:

Let the modified IV be:

where Δ contains one set bit at byte 0. Then:

Nothing about that prediction requires constructing the expected output by first asking OpenSSL what it decrypted. The relationship follows from the CBC equation itself.
That narrow deterministic oracle is exactly what the control needs. It demonstrates that a successful CBC decryption can return specifically altered plaintext while leaving final padding untouched. It does not provide a procedure for recovering keys, probing another system or generalizing the harness into a chosen-ciphertext service.
C02's admission decision therefore remains HOLD_NO_AUTHENTICATION.
Alter an early ciphertext block and reconcile the result
C03 exercises the corresponding ciphertext relationship. It changes byte 0 of the first ciphertext block:
C1[0] ^= 1The effect has two different parts.
First, P1 is recovered from D_K(C1) XOR IV. Because the modified C1 is now an input to the block cipher's inverse function, the fixture does not claim an exact byte-by-byte prediction for the resulting first plaintext block. It requires only that the first 16 bytes differ from the authored first block.
Second, plaintext block 2 is recovered from:

C2 has not changed, so D_K(C2) has not changed. Flipping one bit in C1 therefore flips the corresponding bit in P2. Blocks after P2 depend on C2, C3, and later unchanged ciphertext blocks, so the selected change leaves that later suffix intact under the CBC equations.
The fixture encodes exactly those expectations:
out[:16] != PLAIN[:16]
out[16:] == flip(PLAIN, 16)[16:]The commissioning run returned exit 0, 64 plaintext bytes and a passing block/suffix oracle.
That is an operational example of AES-CBC decryption returning tampered data whose final padding still validates. OpenSSL's documented padding check is limited enough that the command result is entirely consistent with this outcome.
Separate the unpredictable block from the predictable suffix
The review should resist two overstatements.
The first is “we know exactly what corrupted block 1 becomes.” This experiment does not. Changing C1 changes the input to D_K, so the fixture only asserts that the first recovered block differs.
The second is “changing one ciphertext block corrupts everything after it.” That is also not what the CBC equations say here. The altered C1 directly flips the corresponding bit of P2, while P3 and subsequent plaintext blocks use unchanged previous ciphertext inputs.
That is why the independent suffix assertion is valuable. It turns a vague corruption narrative into three separately tested propositions: block 1 changed; byte 0 of block 2 changed exactly as predicted; and the later suffix remained equal to the authored bytes.
The fifth padding block is also unaffected by this early alteration, so exit 0 is not anomalous. The success is precisely the behavior the negative control was built to expose.
Inspect a rejected decryption without releasing its output
C04 moves the change to ciphertext byte 63: the last byte of the fourth ciphertext block.
The original four-block plaintext receives a fifth block of PKCS#7 padding. In this fixture that final block consists of 0x10 bytes. Under CBC decryption, modifying the last byte of C4 directly XORs the corresponding final byte of P5; the controlled one-bit change makes that final padding byte 0x11 rather than 0x10.
The oracle therefore requires only one thing: the decryption process must fail.
That happened in the supplied run. OpenSSL returned exit status 1 and a bad decrypt diagnostic. The measured build also produced 64 bytes on stdout before reporting the final failure.
That observation is important but must be bounded. The fixture does not require a universal 64-byte partial-output length or an exact error-address string. Another build or buffering path may expose different incidental details. The acceptance predicate is the nonzero decryption result.
This is the practical significance of partial output after an OpenSSL bad decrypt error: the existence of stdout is not evidence of successful decryption. OpenSSL's own enc documentation discusses the general problem of streaming output that cannot later be rolled back when a terminal validation fails.
A downstream consumer must therefore make its process verdict before treating captured output as released application data. In this fixture stdout remains merely evidence inside the owned harness. C04 adds no release event.
Once another component has already parsed, stored, indexed or acted on bytes, a later nonzero exit status cannot undo those side effects. That is a downstream reconciliation problem, not something CBC decryption can repair after the fact.
Qualify one authenticated-decryption return boundary
The second half of the experiment changes the primitive and, importantly, the API contract.
The cryptography 50.0.2 AESGCM documentation states that encrypt authenticates the data and associated data, appends a 16-byte tag, and that one-shot decrypt either returns the original plaintext or raises InvalidTag when authentication fails. The documentation says changed ciphertext can cause InvalidTag, but so can an incorrect key, nonce or associated-data value.
That fits the abstraction in RFC 5116: authenticated decryption has one logical output, plaintext or FAIL, relative to K, N, A and C. RFC 5116 does not prescribe the application's record encoding, access-control policy or replay rules.
NIST SP 800-38D, published in November 2007, specifies GCM as authenticated encryption with associated data. Its later revision-planning notice is not evidence that authenticated encryption is a new 2026 feature or that this experiment tests a newly introduced behavior.
G01 calls:
recovered = AESGCM(test_key).decrypt(test_nonce, data, test_aad)Only after that call returns does the harness append:
releases.append({"case": case_id, "plaintext_sha256": digest(recovered)})The separate equality oracle then confirms recovered == PLAIN. That ordering distinguishes an implementation's authenticated return from the application's own release event.
The commissioning result was exact 64-byte equality and one event, producing ACCEPT_AUTHENTICATED_LAB_RECORD.
That acceptance remains deliberately narrow. The Python backend differs from the OpenSSL CLI tested for CBC; the AEAD result does not retroactively authenticate CBC output and does not define a production encrypted-file container.
Bind the associated data that the consumer expects
The fixture authenticates this fixed AAD:
owned-lab-record;format=1;object=fixtureAEAD associated data is authenticated but not encrypted. Both RFC 5116 and the cryptography documentation describe that role.
Operationally, this means the consumer must know which context it expects and supply that value to authenticated decryption. Merely accepting arbitrary metadata because it arrived beside the ciphertext would defeat the point of testing whether the ciphertext belongs to the intended context.
The fixture does not design a metadata serialization protocol. It proves only that its fixed expected AAD participates in the authentication decision: G04 changes one AAD byte and is rejected.
Nor does successful symmetric authentication identify a person, prove freshness, authorize a business action or establish that the secret key itself was uncompromised. It establishes a cryptographic relationship among the key and supplied authenticated inputs under the algorithm's assumptions.
Keep the encryption nonce unique for its key
The fixture generates one fresh AES-GCM key, one 12-byte nonce and performs exactly one encryption under that key/nonce pair.
That boundary matters. The cryptography documentation warns never to reuse a nonce for another encryption with the same key, and RFC 5116 likewise requires nonce distinctness across authenticated-encryption invocations for a fixed key under its stated interface rules.
The later G01 to G06 operations are decryptions. Reusing the stored nonce as an input to repeated decryption controls is not performing another encryption under the pair.
Nothing in this one-record fixture qualifies scalable production nonce allocation. os.urandom(12) is sufficient to create this fresh isolated test value, but the experiment does not establish multi-process coordination, persistence, collision handling or long-lived key policy. A production design must qualify those questions separately.
Reject all five authentication failures without guessing the cause
The useful authenticated-decryption gate needs a positive and negative matrix. G02 through G06 change exactly one class of input at a time while holding the others at their fixture values.
Case | Controlled change | Observed API result | Release events |
G02 | Ciphertext byte 0 XOR 1 | InvalidTag | 0 |
G03 | Last tag byte XOR 1 | InvalidTag | 0 |
G04 | AAD byte 0 XOR 1 | InvalidTag | 0 |
G05 | Nonce byte 0 XOR 1 | InvalidTag | 0 |
G06 | Key byte 0 XOR 1 | InvalidTag | 0 |
This is AESGCM InvalidTag validation, not cause diagnosis. The API documentation expressly says the same exception may result from changed ciphertext or from a wrong key, nonce or associated data.
Accordingly, an unexplained InvalidTag in production does not prove malicious tampering. It proves that the supplied values did not authenticate under that invocation. Diagnosing why is a separate evidence problem.
G04 is also a direct AES-GCM associated data mismatch control: the ciphertext and key are unchanged, yet the expected context differs, so the record is rejected.
The stronger DevSecOps property is the release boundary: every negative has zero release events. This positive/negative structure complements broader automated security testing in DevSecOps by expressing a security claim as executable acceptance behavior rather than checking only the happy path.
The harness catches only InvalidTag for those authentication controls. An unexpected ValueError, backend failure, timeout or implementation exception must not be silently normalized into “authentication correctly rejected.” It instead makes the implementation evidence incomplete and should produce a HOLD pending repair.
Reconcile command results, returned bytes and release events
The ten controls are easiest to review as four evidence channels rather than one “decryption status.”
The CLI process channel applies to CBC. C01 to C03 returned exit 0; C04 returned nonzero. That is decryption exit code validation, but only against the behavior enc actually reports.
The stdout channel records bytes emitted by the CLI. C01 contains the authored plaintext. C02 and C03 contain altered plaintext despite process success. C04 still produced 64 captured bytes on the measured build despite the later failure. No CBC stdout is entered into the release ledger.
The one-shot API channel applies to AES-GCM. G01 returned plaintext; G02 to G06 raised InvalidTag. The cryptography contract says authenticated decrypt returns plaintext on success and raises when the tag does not validate.
Finally, the application event channel is the harness's own ledger. It records one event, for G01, only after decrypt returns.
That distinction is the core of an authenticated decryption release gate. Library-returned bytes and application publication are not identical events. A real application may still need schema validation, authorization, provenance checks, replay controls, durable transactions or atomic publication after authenticated decryption. RFC 5116 explicitly leaves access-control and anti-replay services outside its AEAD interface.
Qualification should therefore require the full case ledger, all_controls_passed, the expected single release event and recorded runtime/source provenance. A familiar exception string alone is not enough. Neither is a matrix in which everything fails: without G01, the harness has not proved that its valid path works.
Separate the acceptance oracle from incidental observations
The fixture requires these properties:
C01 must return the exact authored bytes. C02 must return the exact predicted IV-modified plaintext. C03 must change block 1, apply the expected bit change to block 2 and preserve the later suffix. C04 must fail. G01 must return the authored plaintext and add exactly one event. G02 to G06 must raise InvalidTag and add none.
Other fields are observations.
C04's 64-byte stdout length is recorded but is not its acceptance criterion. A specific OpenSSL error address is not an oracle. Randomly generated ciphertext fingerprints can differ on another valid run because fresh keys, IVs and nonces are used.
Likewise, SHA-256 labels help identify the particular source, executable, plaintext and random artifacts represented in a report. They do not transform a digest supplied alongside untrusted legacy data into authenticity evidence.
The actual plaintext integrity negative controls depend on authored bytes and documented mode relationships, not on asking the decryptor to certify its own output. That independence is what gives C02 and C03 evidentiary value.
If a later runtime contradicts one of those required relationships or the documented API contract, the correct response is not to quietly rewrite the expected outcome. Record the discrepancy and HOLD the affected implementation until it is understood.
Decide whether to reject, hold or recover a trusted source
The admission decision can now be made without pretending that every successful decryption proves the same thing.
Condition | Decision | Reason |
CBC returns 0 and bytes, but no independently trusted authentication mechanism exists | HOLD_NO_AUTHENTICATION | Process success and padding validity do not establish authenticity |
CBC returns nonzero | REJECT_DECRYPTION_FAILURE | Operation failed; emitted stdout is not releasable |
AEAD raises InvalidTag | REJECT_AUTHENTICATION_FAILURE | Inputs did not authenticate under the invocation |
Qualified AEAD positive returns, equals the laboratory oracle and creates the expected event | ACCEPT_AUTHENTICATED_LAB_RECORD | The bounded lab admission contract passed |
Runtime provenance, negative controls, wrapper behavior or event ordering is missing/unexpected | HOLD_IMPLEMENTATION_UNQUALIFIED | Evidence is insufficient for release |
For a successful legacy CBC decryption, HOLD is not a request to keep retrying until the bytes look plausible. Re-running the same corrupt or untrusted ciphertext does not manufacture provenance. Widening the parser's acceptance rules does not supply authentication either.
Re-encrypting recovered legacy plaintext under a new authenticated scheme must be interpreted with equal care. A new AEAD ciphertext can authenticate the bytes that were newly submitted to that encryption operation. It cannot retroactively prove that those bytes were the authentic historical contents of the old CBC record.
When provenance matters and cannot be established from the legacy object, recovery means withholding its use, preserving the relevant evidence and obtaining the record from a separately trusted source under the approval of the responsible data owner. That ownership boundary aligns with broader incident response and recovery ownership, where responsibilities and recovery actions need to be assigned rather than improvised after a failure.
Stopping a consumer also cannot reverse effects it already committed. If suspect bytes were written to a database, used to trigger another operation or published to another system, that application needs its own reconciliation procedure. The fixture deliberately does not claim to test such recovery.
Assign ownership and requalification triggers
A durable control needs named ownership because three different questions are being answered.
The data or provenance owner decides what source is authoritative enough for business use. That owner determines whether a legacy record with no authentication evidence may be recovered from another source, discarded, quarantined or handled under some documented exception. Cryptographic success cannot make that governance decision.
The cryptographic-integration owner is responsible for the implementation contract: exact executable or library, algorithm/mode, key and IV/nonce handling, expected AAD, wrapper behavior and error semantics. For this laboratory, that includes preserving the difference between the OpenSSL 3.0.13 CLI and the cryptography backend reporting OpenSSL 4.0.3.
The release-gate owner is responsible for when bytes become consumable application data. The important review question is not merely where a decrypt function is called; it is whether any parse, write, event, callback or downstream action can occur before the required decryption/authentication verdict.
Requalification should be triggered when a material part of that path changes: the OpenSSL executable or backend, provider/configuration, Python package, decryption method, subprocess wrapper, exception handling, expected associated-data context, input record assumptions or downstream release sink.
Key rotation, historical key dependencies and encrypted-record migration belong to separate lifecycle work. Refonte Learning's article on encrypted-data migration and key dependencies illustrates why a key-routing or migration operation needs its own evidence rather than being inferred from a configuration change; this local fixture intentionally contains no KMS commands, retirement workflow or bulk migration procedure.
Several production contracts remain outside the result even after G01 passes: durable publication, atomic replacement, semantic/business validation, authorization, replay protection, historical provenance and deployment key management. RFC 5116 specifically excludes access control and anti-replay services from its AEAD interface, and it does not dictate a production record encoding.
No green result here is therefore a general cryptographic security certificate. It qualifies one laboratory question: whether the tested implementation releases plaintext only across the return boundary demonstrated by these controls.
Strengthen the security review skills behind the gate
The useful skill in this experiment is not memorizing that CBC and GCM are different. It is learning to read a tool's guarantee precisely, write an independent oracle, test both valid and invalid paths, keep incidental output separate from acceptance criteria, preserve runtime evidence and escalate a HOLD when the implementation has not earned release.
Those practices map directly to areas currently listed by Refonte Learning's Cybersecurity & DevSecOps programme: Cryptography and Secure Data Management, encryption techniques, secure CI/CD pipelines, DAST/SAST/IAST, OWASP and threat modeling, incident response and security-workflow automation. The programme page also describes practical virtual-internship work and states that successful completion can result in a Training Certificate and Certificate of Internship.
That verified curriculum coverage is a sensible place to continue developing the review habits exercised here: precise security contracts, reproducible negative controls and evidence-based release decisions. It should not be read as a claim that this exact OpenSSL CBC/AESGCM fixture, its nonce-management details or this authentication-gate implementation is specifically taught there.
