A reviewer can supply the intended certificate as A.pem, run openssl cms -verify -certfile A.pem, and still successfully verify a detached CMS object signed with certificate B when B is embedded in that CMS object. That outcome is not a contradiction. In OpenSSL 3.0, -certfile supplies additional certificates that may be searched for signer certificates; without -nointern, the verifier can also search certificates carried inside the CMS message. -nointern narrows signer-certificate lookup to the externally supplied candidates, but it is not, by itself, a complete application authorization policy. OpenSSL's 3.0 openssl-cms manual and the CMS_verify() API documentation describe that lookup order explicitly.
That distinction matters because a CMS consumer has at least four different questions to answer: which certificate was located for the signer, whether its certification path passed verification, whether the detached content and CMS signature verified, and whether the application is willing to admit that verified signer. The preparation evidence for this article fixes the last question independently: only exact certificate A is permitted.
The supplied nine-case laboratory uses two fresh same-subject leaf certificates, A and B; one owned issuing root; one unrelated root; detached single-signer DER CMS; and an independent SHA-256 inventory of the two certificate DER encodings. The preparation run reported PASS_9_CASES on Python 3.12.14 and OpenSSL CLI/library 3.0.13. The key counterexample is C02: -certfile A.pem is present, yet embedded B verifies. C03 and C04 then show what changes when -nointern is added.
Define the signer this consumer is allowed to accept
The admission rule must exist before inspecting verifier output:
Accept only a successfully verified, single signer whose certificate DER SHA-256 equals the known fingerprint of owned fixture certificate A.
That rule is deliberately narrower than “accept any certificate issued by this root,” “accept this subject name,” or “accept anything that OpenSSL verified.” A is a fixture certificate identity, not evidence of a human identity, an organization-wide entitlement, or authorization to perform an external business action. Its SHA-256 value identifies the exact certificate bytes generated for one preparation run.
This separation turns C02 from a confusing success into an ordinary policy result. OpenSSL can successfully verify B because its default signer lookup is allowed to find B inside the CMS object. The application can then inspect the signer actually used and return REJECT_SIGNER_POLICY because that verified certificate is not A. Conversely, an anticipated cryptographic or lookup failure is REJECT_VERIFICATION. An unexpected tool result, missing evidence, altered case inventory or unknown runtime is HOLD, not an improvised accept or reject.
That is a different boundary from artifact provenance and release authorization, where the policy concerns artifact origin and promotion. The present problem is intentionally smaller: which certificate OpenSSL may use as the signer for one CMS SignerInfo, and what the local consumer does after learning that fact. The referenced Refonte article likewise treats cryptographic verification and downstream approval as separable decisions.
The term CMS certfile allowlist is therefore incomplete unless the design states both how signer lookup is restricted and what independent admission predicate is enforced. A file named allowed.pem is not a policy merely because its filename suggests one.
Separate signer candidates from trust anchors
Four certificate roles are easy to blur in an integration review.
Evidence or input | Role in this laboratory |
Certificates embedded in the CMS SignedData | Potential signer-certificate source under ordinary lookup; may also provide untrusted material relevant to path construction |
-certfile A.pem or AB.pem | External candidate material; searched for signer certificates and usable as untrusted chain material |
-CAfile root.pem | Explicit trusted certificate material for chain verification |
-signer case.signers.pem after successful verification | The certificate OpenSSL reports as the successfully verified signer |
The maintained OpenSSL 3.0 CMS command documentation says -certfile allows additional certificates to be supplied for verification, while -nointern changes signer lookup so only certificates supplied with -certfile are used to locate signing certificates. It also says that certificates contained in the message can still be used as candidate intermediates. -signer, in verification mode, writes the signer certificate after successful verification.
The API-level wording is even clearer. OpenSSL 3.0 CMS_verify() first searches the supplied certs parameter for an unassociated signer certificate, then searches certificates contained in the CMS structure unless CMS_NOINTERN is set. After lookup, each signer certificate is chain-verified against the supplied trust store, and only later is content read and the signature checked. OpenSSL documents CMS_NOINTERN as one possible way to accept messages signed by a small supplied set because a signer outside that set cannot be found through normal internal lookup.
Do not mix the C API's return convention with the command-line process convention. CMS_verify() returns 1 for successful verification and 0 on error, whereas the openssl cms program documents process exit code 0 for complete success. The fixture additionally uses -verify_retcode, which requests a nonzero result when verification itself fails.
Trust is a separate input. OpenSSL 3.0 verification options describe -CAfile as loading certificate material into the trust store; -no-CApath and -no-CAstore suppress default directory and store sources. Putting A in -certfile does not make A a root trust anchor.
That separation is also why TLS hostname verification controls are outside this experiment. That article deals with transport identity and hostname predicates. Here there is no listener, SNI, DNS name, network handshake or endpoint identity.
Keep lookup restrictions separate from chain building
-nointern is specifically a signer-lookup control. It does not mean “ignore every embedded certificate for every verification purpose.” The OpenSSL 3.0 CMS manual explicitly notes that internal certificates may still act as untrusted candidates during chain construction, and the API documentation similarly describes internal CMS certificates as available for chain building.
After a signer certificate is found, successful verification still depends on certificate validation and the signature/content checks that remain enabled. The fixture does not use -noverify, -nosigs, -no_content_verify or -no_attr_verify; those options would disable checks rather than prove the desired configuration works. OpenSSL's CMS documentation identifies those switches as verification bypasses, and the API documentation warns that disabling content verification causes modified content to be treated as valid for that check.
The security review therefore has to ask four independent questions: was the intended candidate-selection rule applied; did path validation pass against the intended trust configuration; did content/signature verification pass; and did the locally declared signer policy accept the resulting certificate?
Establish two same-subject certificates and an independent oracle
The fixture creates two self-contained roots, root and otherroot, using openssl req -x509. It then creates independent RSA-2048 keys and CSRs for A and B with the same leaf subject, /CN=Same Owned Signer Label. The owned root signs A with serial 101 and B with serial 102. The leaf certificate extensions are explicitly supplied as basicConstraints=critical,CA:FALSE, keyUsage=critical,digitalSignature, extendedKeyUsage=emailProtection, subjectKeyIdentifier=hash, and authorityKeyIdentifier=keyid,issuer.
These are laboratory construction choices, not claims about universal CMS certificate profiles. OpenSSL 3.0 openssl-req documents -newkey, -x509, subject and extension controls used to build the owned fixtures. OpenSSL 3.0 openssl-x509 documents -set_serial and notes that CSR extensions are not copied into issued certificates by default unless requested; that is why the driver supplies the leaf extension file explicitly.
The same display subject is intentional. RFC 5652's SignerIdentifier is not simply a display-name comparison: it can contain issuerAndSerialNumber or a subjectKeyIdentifier. The fixture exercises the default OpenSSL signing behavior without -keyid; it does not claim to cover alternate identifier forms. A human-readable subject string therefore cannot be the oracle for whether A or B was used. RFC 5652, published in September 2009, defines those SignerIdentifier choices and places public-key selection and validation in the wider verification context.
The authored detached payload is exactly 33 bytes:
owned-cms-record-17: review onlyThe preparation driver records A and B's DER certificate SHA-256 digests before running the cases, asserts that their displayed subjects are identical, and asserts that their certificate digests differ. That inventory is the independent signer oracle.
Bind the local policy to the known certificate bytes
The application rule compares the DER encoding of the successfully verified signer certificate to the independently recorded DER SHA-256 of A. It does not hash the filename A.pem, the shared subject, or the CMS message.
OpenSSL's X.509 tooling supports DER output, which the fixture hashes. A certificate fingerprint in this context identifies one certificate encoding; it does not by itself identify a person or confer business authority. The OpenSSL 3.0 openssl-x509 documentation describes certificate format conversion and the issuance controls used by the driver.
Because fresh keys and certificates are generated on each run, a rerun will naturally produce new A and B fingerprints and new CMS bytes. The stable expectation is semantic: the fixture's newly generated A is the permitted signer and B is not. Certificate replacement or renewal in a real application would therefore require an explicit policy decision rather than an assumption that a matching subject means “same signer.”
Run the complete nine-case CMS laboratory
Save the following source as cms_signer_fixture.py in a writable workspace. The source below is the supplied preparation source byte-for-byte with LF line endings and a final newline. Its SHA-256 is:
38689e5cf0ee5c8660b058d48d4840afc55bb3e1918ddd481eb79495fe6468c2That digest identifies this source text. It does not attest that the file ran. The supplied preparation evidence separately records execution from 2026-11-02T08:05:46.197909+00:00 to 2026-11-02T08:05:49.894820+00:00, Python 3.12.14, /usr/bin/openssl, OpenSSL CLI and library 3.0.13 dated 30 Jan 2024, Linux x86_64 with glibc 2.39, and aggregate status PASS_9_CASES. Those timestamps identify the run; they are not benchmark measurements.
python3 cms_signer_fixture.py > cms_signer_results.jsonA rerun should be treated as new evidence: record the actual executable, runtime and JSON rather than copying the preparation result. The driver sets an owned OPENSSL_CONF, creates its roots, leaves, detached CMS objects and content itself, and removes its temporary directory on normal completion. It does not establish full environment hermeticity, crash cleanup or secure erasure. The one-day certificates and unencrypted fresh test keys are disposable laboratory choices. OpenSSL documents the relevant req, certificate-generation and CMS controls, but those choices are not deployment recommendations.
"""Owned single-signer CMS lookup controls; no external service or approval."""
import hashlib, json, os, platform, re, shutil, subprocess, sys, tempfile
from datetime import datetime, timezone
from pathlib import Path
PAYLOAD = b'owned-cms-record-17: review only\n'
ROWS = []
def require(ok, label):
if not ok:
raise RuntimeError(label)
def digest(data):
return hashlib.sha256(data).hexdigest()
def run(exe, directory):
(directory/'openssl.cnf').write_text('[req]\ndistinguished_name=dn\n[dn]\n')
env = dict(os.environ, OPENSSL_CONF=str(directory/'openssl.cnf'))
def cmd(*args, checked=True):
p = subprocess.run([exe, args], cwd=directory, env=env,
capture_output=True, timeout=20)
if checked:
require(p.returncode == 0, 'Setup command failed: ' + p.stderr.decode(errors='replace'))
return p
for name in ['root', 'otherroot']:
cmd('req','-x509','-newkey','rsa:2048','-noenc','-batch',
'-config','openssl.cnf','-subj','/CN=Owned CMS ' + name,
'-keyout',name+'.key','-out',name+'.pem','-days','1','-sha256',
'-addext','basicConstraints=critical,CA:TRUE',
'-addext','keyUsage=critical,keyCertSign,cRLSign',
'-addext','subjectKeyIdentifier=hash')
(directory/'leaf.ext').write_text('[leaf]\nbasicConstraints=critical,CA:FALSE\n'
'keyUsage=critical,digitalSignature\nextendedKeyUsage=emailProtection\n'
'subjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid,issuer\n')
fingerprints, subjects = {}, []
for name, serial in [('A', '101'), ('B', '102')]:
cmd('req','-new','-newkey','rsa:2048','-noenc','-batch',
'-config','openssl.cnf','-subj','/CN=Same Owned Signer Label',
'-keyout',name+'.key','-out',name+'.csr')
cmd('x509','-req','-in',name+'.csr','-CA','root.pem','-CAkey','root.key',
'-set_serial',serial,'-days','1','-sha256','-extfile','leaf.ext',
'-extensions','leaf','-out',name+'.pem')
fingerprints[name] = digest(cmd('x509','-in',name+'.pem','-outform','DER').stdout)
subjects.append(cmd('x509','-in',name+'.pem','-noout','-subject').stdout)
require(subjects[0] == subjects[1] and fingerprints['A'] != fingerprints['B'],
'Same display subject, distinct certificates required')
(directory/'AB.pem').write_bytes((directory/'A.pem').read_bytes() +
(directory/'B.pem').read_bytes())
(directory/'content.bin').write_bytes(PAYLOAD)
(directory/'changed.bin').write_bytes(PAYLOAD + b'changed\n')
for name, certificate, extra in [('A','A',[]), ('B','B',[]), ('A_external','A',['-nocerts'])]:
cmd('cms','-sign','-binary','-in','content.bin','-signer',certificate+'.pem',
'-inkey',certificate+'.key','-outform','DER','-out',name+'.cms',
'-md','sha256','-nosmimecap',extra)
# ID, message, candidate file, nointern, content, trust, success, signer, error class.
cases = [
('C01','A',None,False,'content.bin','root',True,'A',None),
('C02','B','A.pem',False,'content.bin','root',True,'B',None),
('C03','A','A.pem',True,'content.bin','root',True,'A',None),
('C04','B','A.pem',True,'content.bin','root',False,None,'signer certificate not found'),
('C05','A_external','A.pem',True,'content.bin','root',True,'A',None),
('C06','A_external',None,False,'content.bin','root',False,None,'signer certificate not found'),
('C07','A','A.pem',True,'changed.bin','root',False,None,'content verify error'),
('C08','A','A.pem',True,'content.bin','otherroot',False,None,'certificate verify error'),
('C09','B','AB.pem',True,'content.bin','root',True,'B',None),
]
for identity, message, candidates, restricted, content, trust, expected, signer, error in cases:
output, signers = identity+'.out', identity+'.signers.pem'
args = ['cms','-verify','-verify_retcode','-binary','-inform','DER',
'-in',message+'.cms','-content',content,'-CAfile',trust+'.pem',
'-no-CApath','-no-CAstore','-out',output,'-signer',signers]
if candidates:
args += ['-certfile', candidates]
if restricted:
args += ['-nointern']
p = cmd(*args, checked=False)
succeeded = p.returncode == 0
require(succeeded == expected, identity + ' verifier outcome')
verified_fingerprint = None
if succeeded:
require((directory/output).read_bytes() == PAYLOAD, identity + ' authored content')
pem = (directory/signers).read_bytes()
require(len(re.findall(b'-----BEGIN CERTIFICATE-----',pem)) == 1,
identity + ' single signer required')
verified_fingerprint = digest(cmd('x509','-in',signers,'-outform','DER').stdout)
require(verified_fingerprint == fingerprints[signer], identity + ' signer oracle')
else:
require(error in p.stderr.decode(errors='replace').lower(), identity + ' rejection class')
accepted = succeeded and verified_fingerprint == fingerprints['A']
require(accepted == (identity in ['C01','C03','C05']), identity + ' owned A-only policy')
ROWS.append({'case':identity,'arguments':args,'expected_verification':expected,
'openssl_returncode':p.returncode,'openssl_verified':succeeded,
'verified_signer_sha256':verified_fingerprint,'expected_signer_label':signer,
'expected_error_fragment':error,'stderr':p.stderr.decode(errors='replace'),
'staged_output_bytes':(directory/output).stat().st_size if (directory/output).exists() else 0,
'accepted_for_owned_A_policy':accepted,
'decision':'ACCEPT_OWNED_A_SIGNER' if accepted else
'REJECT_SIGNER_POLICY' if succeeded else 'REJECT_VERIFICATION',
'status':'PASS'})
require([r['case'] for r in ROWS] == ['C%02d' % i for i in range(1,10)], 'case inventory')
return {'certificate_sha256':fingerprints,'same_subject':True,'payload_sha256':digest(PAYLOAD)}
if name == '__main__':
report = {'started_utc':datetime.now(timezone.utc).isoformat(),
'python':platform.python_version(),'platform':platform.platform(),
'source_sha256':digest(Path(__file__).read_bytes()),'cases':ROWS,
'scope':'Owned detached single-signer CMS; local certificates and files; no business operation.'}
try:
exe = shutil.which('openssl')
require(exe is not None, 'OpenSSL executable required')
report.update(openssl_path=exe,openssl_sha256=digest(Path(exe).read_bytes()),
openssl_version=subprocess.run([exe,'version','-a'],capture_output=True,
check=True,timeout=10,text=True).stdout)
with tempfile.TemporaryDirectory(prefix='owned-cms-') as owned:
report.update(run(exe,Path(owned)))
report.update(status='PASS_9_CASES',normal_temporary_cleanup_completed=True)
code = 0
except Exception as error:
report.update(status='HOLD',error_type=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)The expected outcome, signer label and diagnostic class are declared in the cases array before each subprocess result is evaluated. The authored payload is fixed in PAYLOAD; A-only admission is a separate predicate. That structure prevents a rerun from redefining success to match whatever happened.
Successful rows additionally require byte-for-byte equality with the authored payload, exactly one certificate in the successful -signer output and a signer DER digest equal to the expected A or B inventory entry. Failed rows require the intended diagnostic class. Any setup error, timeout, unexpected exit behavior, diagnostic mismatch or assertion failure escapes to the top-level HOLD.
The historical JSON can preserve commands, digests and results, but normal temporary cleanup removes the raw generated CMS, certificates and keys. A later run recreates the case design with fresh cryptographic material; it does not recreate the historical CMS bytes.
Show embedded signer B surviving -certfile A
C01 establishes the ordinary embedded-certificate path. The A-signed CMS carries A, no -certfile is supplied, root.pem is trusted, and verification succeeds with A.
C02 changes the signed object to B and supplies -certfile A.pem, while leaving -nointern absent. Verification still succeeds and the signer oracle reports B. The local decision is therefore REJECT_SIGNER_POLICY.
That is the central answer to the operational question. In OpenSSL 3.0, supplying A through -certfile does not by itself restrict verification to A. The CLI documentation says -certfile provides additional certificates to search for signers, while ordinary verification also searches certificates contained in the message. The CMS_verify() documentation gives the order: externally supplied certificates first, then CMS-internal certificates unless CMS_NOINTERN is enabled.
C02 is not evidence of a generic certificate-confusion vulnerability. B is the certificate referenced by the CMS signer information and its signature verifies under the configured path. The experiment shows a configuration-assumption error: treating the presence of A in an extra certificate file as though it meant “only A is eligible.”
The shared subject strengthens that control. Both leaves display /CN=Same Owned Signer Label; only their keys, serials and DER fingerprints distinguish them in this fixture. RFC 5652 supports signer identification through issuer-and-serial or subject-key identifier, not an informal comparison of a displayed common name.
Read the actual verified signer, not the supplied filename
For successful verification, the fixture requests -signer C02.signers.pem. OpenSSL documents that verification-mode -signer writes the signer certificate when verification succeeds. The driver then asserts that the file contains exactly one PEM certificate, converts it to DER and compares its SHA-256 with the precomputed A/B inventory.
That evidence answers “which certificate actually verified?” The fact that A.pem appeared in the command line answers a different question: which external candidate material was supplied.
Do not treat a signer-output file from a failed verification as authenticated signer evidence. The fixture only consumes that output in the successful branch, consistent with the API documentation's rule that signer extraction is meaningful after successful verification.
The review check should therefore be explicit: “verification succeeded; one verified signer certificate was returned; its DER digest equals the independently declared A fingerprint.” A command-line filename is configuration evidence, not signer identity evidence.
Limit signer lookup with -nointern
C03 and C04 form the cleanest OpenSSL CMS certfile/nointern pair.
Both use -certfile A.pem, the same owned root, the same detached-content rules, -verify_retcode, -binary, -no-CApath and -no-CAstore. Both add -nointern. Only the message signer changes.
C03 uses embedded A. OpenSSL can find A in the externally supplied candidate source, verification succeeds, the returned signer fingerprint matches A, and the local decision is ACCEPT_OWNED_A_SIGNER.
C04 uses embedded B. Because -nointern prevents internal CMS certificates from being searched to locate the signer, B cannot be found from A.pem; the preparation run observed the expected “signer certificate not found” class and recorded REJECT_VERIFICATION. The OpenSSL CMS manual describes exactly this effect, and the API equivalent is CMS_NOINTERN.
This is narrower than saying “-nointern authorizes A.” It does not. It constrains the source used to locate signing certificates. If the supplied candidate file contains B as well, B can become discoverable again, as C09 demonstrates.
It is also not the only defensible way to enforce A-only admission. C02 illustrates another architecture: allow default lookup, complete cryptographic verification, extract the successful signer, and apply an exact A-fingerprint policy afterward. If that policy check is implemented correctly and fail-closed, it rejects B. -nointern is valuable because it can narrow lookup earlier, but a lookup restriction and a business admission decision remain different controls.
Verify an external signer when the message contains no certificate
The fixture creates A_external.cms with -nocerts. OpenSSL documents -nocerts as omitting signer certificates that would normally be carried in the signed message; the verifier then needs a copy from another source, such as -certfile. The signature is still present.
C05 verifies that external path directly. The CMS has no embedded signer certificate, A.pem is supplied, and -nointern is set. OpenSSL locates A externally, validates its chain to root.pem, verifies the detached content and signature, and the fixture's signer oracle reports A. The A-only policy accepts it.
C06 removes the external candidate as well. The message still has its signature, but there is no certificate available from the tested signer-lookup sources. The expected result is a signer-not-found verification failure.
Together, C05 and C06 answer a useful review question: is the integration really capable of locating its signer externally, or has a passing test silently depended on signer material embedded in the CMS? A negative -nocerts control exposes that dependency.
A missing signer certificate is not a reason to disable signature or certificate verification. It is evidence that the consumer lacks the material required by its lookup design. The corrective action is to restore the intended candidate source or investigate why it is absent, not to add -nosigs, -noverify or another bypass. Those flags explicitly suppress verification functions in OpenSSL's CMS interface.
The fixture does not test certificate renewal, alternate signer identifiers or intermediate-chain arrangements. It proves one external lookup boundary for one detached signer.
Preserve trust validation with the intended signer candidate
C08 holds the intended signer candidate constant and changes the trust input. The command still supplies A.pem and uses -nointern; the content and A-signed CMS are unchanged. The only semantic change is that -CAfile otherroot.pem replaces root.pem.
The expected result is certificate-path rejection. The preparation run recorded the diagnostic class certificate verify error and process return code 52. That number is a measured OpenSSL 3.0.13 CLI observation, not a portable constant that an integration should hard-code across releases or builds.
The important boundary is portable: finding the correct signer candidate does not make that candidate trusted. -certfile supplies candidate/untrusted certificate material, while -CAfile populates trusted certificate material used for chain validation. The verification-options documentation separately describes -CAfile, -CApath and -CAstore as trust sources.
The fixture also passes -no-CApath and -no-CAstore so default directory and store locations do not supplement this test's explicit root choice. OpenSSL documents those switches as disabling the corresponding default trust sources.
That does not prove that every inherited environmental influence or provider choice has been eliminated. The driver controls OPENSSL_CONF and explicit roots, but it does not claim complete process hermeticity or qualify every host's provider configuration.
Reject the wrong root without bypassing verification
When an expected A certificate suddenly fails chain validation, the investigation package should retain the exact command arguments, candidate certificate digest, root digest, OpenSSL executable identity and version, diagnostic class and case identifier.
The operational owner should then determine whether the trust configuration is wrong, the certificate changed, the runtime changed or the intended policy itself changed. Adding a new root is a trust-policy modification and should undergo separate review.
Do not “fix” C08 by adding -noverify. OpenSSL documents -noverify as disabling signer-certificate verification. Making an intentionally negative path pass by removing the predicate under test destroys the evidence rather than repairing the integration.
The same reasoning applies to -nosigs, -no_content_verify and -no_attr_verify: they change what is being verified. This laboratory's evidence depends on leaving those checks enabled.
Widen the candidate file and observe the policy change
C09 keeps -nointern but changes -certfile A.pem to -certfile AB.pem, where the external file contains both owned fixture certificates.
The CMS is signed by B. Because B is now present in the restricted external candidate source, OpenSSL can locate it, validate it to the owned root and successfully verify the CMS. The signer oracle reports B. The independent policy remains A-only, so the correct decision is REJECT_SIGNER_POLICY.
That row is why “use -nointern” is still not a complete authorization design. -nointern restricts where OpenSSL searches for a signer certificate; the contents of that permitted source determine which signer certificates remain discoverable. OpenSSL's documentation says that with -nointern, signer lookup is confined to certificates supplied through -certfile.
Candidate-file maintenance and application signer authorization should therefore be separately owned. The maintainer responsible for AB.pem can change the lookup universe by adding or removing certificates. The application-security or service owner responsible for the A-only admission rule decides which successfully verified signer identities are actually acceptable to the consumer.
Those decisions can coincide operationally, but they should not be conflated in evidence. Broadening a candidate bundle does not automatically broaden business permission. Nor does a shared issuer or the identical subject label make B interchangeable with A.
For this fixture, certificate equality is deliberately byte-specific. A real system may define another approved signer identity model, but that model must be declared independently and reviewed on its own terms.
Quarantine output from a failed content check
C07 changes the detached content from the authored 33-byte payload to the payload plus changed\n. A remains the external signer candidate, -nointern remains enabled and the owned root remains trusted. Signer lookup is therefore not the intended failure point. Content verification is.
The preparation run observed the expected content verify error, process return code 32 and a 41-byte staged output file. The exact byte count and return code describe this OpenSSL 3.0.13 run; they are not cross-version contracts.
The architectural lesson is documented by the CMS_verify() API itself: OpenSSL reads the signed content and writes it to the output BIO before the final signature-verification result is known. The documentation explicitly cautions about altered content when verification checks are disabled.
Therefore, file existence, a nonzero length or recognizable plaintext is not evidence that the content passed verification. The fixture treats successful process status, exact authored content and successful signer identity as separate requirements.
This is conceptually adjacent to Refonte Learning's article on decryption output and authentication evidence, but the cryptographic mechanism is different. That article studies unauthenticated CBC output; this laboratory already has CMS signatures and asks which signing certificate and verification result govern admission.
Keep staged bytes outside the admission decision
A production integration should treat verifier output as private staging until every required gate has succeeded. For this narrow design, that means: the verifier succeeded, the intended detached content was checked, exactly one verified signer was identified, and the application's signer policy accepted that signer.
The driver models this boundary with accepted_for_owned_A_policy; it performs no publication or external operation. C07's 41 bytes are recorded only as evidence that output may exist despite failed verification.
This laboratory does not test atomic publication, durable rollback, filesystem permission hardening, secure deletion or any external authorization. Those belong to a larger integration design. The defensible conclusion here is simply that failed-verification output must not be admitted because it exists.
Reconcile the complete case ledger
The preparation run reported the following complete inventory. “A” and “B” mean the known certificate entries created before case evaluation. “none” means no verified signer is admitted from the failed operation.
Case and setup | Expected verification / signer | Observed exit / signer / staged bytes | Local decision |
C01: embedded A, no certfile | Success / A | 0 / A / 33 | ACCEPT_OWNED_A_SIGNER |
C02: embedded B, certfile A, ordinary lookup | Success / B | 0 / B / 33 | REJECT_SIGNER_POLICY |
C03: embedded A, certfile A, nointern | Success / A | 0 / A / 33 | ACCEPT_OWNED_A_SIGNER |
C04: embedded B, certfile A, nointern | Failure: signer not found | 32 / none / 0 | REJECT_VERIFICATION |
C05: A without embedded certificate, certfile A, nointern | Success / A | 0 / A / 33 | ACCEPT_OWNED_A_SIGNER |
C06: A without embedded certificate or certfile | Failure: signer not found | 32 / none / 0 | REJECT_VERIFICATION |
C07: A, certfile A, nointern, changed content | Failure: content verification | 32 / none / 41 | REJECT_VERIFICATION |
C08: A, certfile A, nointern, unrelated root | Failure: certificate verification | 52 / none / 0 | REJECT_VERIFICATION |
C09: embedded B, certfile A+B, nointern | Success / B | 0 / B / 33 | REJECT_SIGNER_POLICY |
All nine rows passed their fixture assertions, including negative controls whose expected result was rejection. Aggregate status was PASS_9_CASES; the exact supplied source digest was 38689e5cf0ee5c8660b058d48d4840afc55bb3e1918ddd481eb79495fe6468c2, and the recorded execution interval was 2026-11-02T08:05:46.197909+00:00 through 2026-11-02T08:05:49.894820+00:00.
Only C01, C03 and C05 meet the independent A-only signer rule. C02 and C09 are particularly important because OpenSSL verification succeeds while local admission rejects the actual signer. C04, C06, C07 and C08 are verification rejections for different reasons: lookup, lookup, content and path validation respectively.
Keeping those classes distinct makes investigation materially easier. That is the same general observability principle behind recording separate authorization and failure classes rather than collapsing everything into one “failed” bit; Refonte Learning's broader article on API security and observability similarly emphasizes structured evidence and distinct decision records.
The numeric 32 and 52 values and staged lengths are measurements to retain when diagnosing this historical preparation run. The reusable qualification questions are semantic: Did verification succeed? Which signer was verified? What rejection class occurred? Did the independently declared policy admit that signer?
Choose accept, reject or hold from the evidence
A compact decision matrix keeps verifier state and application policy from bleeding into each other.
Decision | Required evidence | Operational meaning |
ACCEPT_OWNED_A_SIGNER | Verification succeeds; exactly one successful signer is reported; its DER digest equals the independently inventoried A; the controlled lab output equals authored content | This fixture satisfies the local A-only signer rule |
REJECT_SIGNER_POLICY | Cryptographic/path verification succeeds, but the successfully verified signer is not A | Verification is valid; admission is not |
REJECT_VERIFICATION | The expected signer lookup, certificate-path, content or signature check fails | Do not admit content from that operation |
HOLD | Missing case, unknown binary, incomplete JSON, unexpected diagnostic, altered source without requalification, assertion failure, unsupported scope assumption or ambiguous evidence | Stop the admission decision and assign investigation |
HOLD is not shorthand for “OpenSSL vulnerability.” It means the current evidence does not justify one of the planned accept/reject determinations. An unexpected result could come from a changed runtime, configuration, certificate, provider, case definition or integration wrapper.
The application's real authorization model may also require predicates outside this experiment. Exact-certificate A is sufficient only because the commissioning policy deliberately defines that narrow rule. The laboratory makes no claim about user identity, organizational authority, release approval, sender identity or external business permission.
This is also why the safest summary of OpenSSL CMS restrict signer behavior is conditional: -nointern restricts ordinary signer-certificate lookup to supplied candidates, while successful chain/content verification and a separately declared admission policy remain necessary. OpenSSL documents the lookup rule; the application owns the admission rule.
Assign certificate policy, recovery and requalification owners
A durable integration should make ownership explicit rather than leaving certificate files and trust roots as anonymous deployment assets.
Candidate-file owner (backend integration maintainer): controls which certificates are supplied through -certfile, reviews additions/removals and records bundle digests. Any change can alter the signer certificates discoverable under -nointern.
Trust owner (PKI or platform-security owner): controls -CAfile and any permitted trust sources. A change here affects path validation, not the application's A-only signer rule.
Signer-policy owner (application-security or service owner): defines which successfully verified signer certificate identities are admitted. For this laboratory, that rule is the exact fresh A certificate inventory entry.
Evidence owner (security testing or integration assurance): preserves the driver digest, runtime identity, arguments, expected case inventory, JSON and review outcome needed to requalify a change.
Operational reviewer (change approver): verifies that a proposed runtime, certificate or configuration change has the required evidence and that a HOLD was not silently converted into acceptance.
Requalification should be triggered when a change can affect any qualified predicate: the OpenSSL executable or linked library/provider behavior, configuration inputs, candidate-file contents, trust roots, leaf certificate replacement, CMS message form, detached-content handling, signer extraction logic or application admission code.
That principle fits the broader practice of regression-oriented automated security testing in DevSecOps: controls should be repeatable and tied to explicit failure policy. The specific regression matrix here remains C01–C09 rather than a generic scanner suite.
A rerun should not be described as reproducing the historical certificate identities. The script generates new keys, new A/B certificates and new signatures. It reproduces the case structure and expectations. Record the new A/B fingerprints and the new source/runtime evidence with that run.
Similarly, source SHA-256 is necessary evidence but not execution evidence. If cms_signer_fixture.py changes by even one byte, the provided digest no longer identifies it. The correct response is to record a new source digest and run the changed driver rather than borrowing PASS_9_CASES from the earlier source.
The maintained OpenSSL 3.0 documentation is the semantic baseline used here; the executable in the preparation evidence was specifically 3.0.13. Accessing documentation on November 2, 2026 does not make 3.0.13 a 2026 release or establish it as the newest OpenSSL version. The qualification is version-specific evidence around established CMS functionality, not a claim about a newly introduced feature.
Recover policy without rewriting historical verification
Suppose a later review discovers that a candidate bundle accidentally broadened from A to A+B, or that a trust file changed. The proposed recovery procedure is to hold affected new inputs, restore an approved configuration when authorized, rerun the qualification matrix, and re-evaluate retained original CMS objects plus detached content where the actual consumer preserves them.
That last condition matters. This laboratory deletes its generated CMS objects during normal temporary cleanup. It therefore cannot retrospectively re-verify the historical bytes after the run ends. A production consumer that needs historical re-evaluation would have to preserve the original message, detached content, relevant policy version, certificate/trust inputs and runtime evidence under its own retention rules.
Changing an allowlist today does not prove an earlier acceptance was correct. Re-signing data today does not establish which certificate verified an earlier message. If historical material or the controlling policy version is missing, the defensible result is uncertainty assigned to review, not reconstructed evidence.
Rollback also must not be overstated. This experiment performs no external business operation, so it does not test undoing one. Restoring an old candidate file merely changes future lookup behavior; it does not reverse a downstream action that a different system may already have taken.
Develop the cryptographic review skills behind this workflow
The transferable skill is not memorizing another OpenSSL switch. It is learning to separate certificate-source selection, certificate-path trust, content/signature verification and application admission, then require evidence for each.
For an OpenSSL CMS consumer, a strong review asks whether -certfile is merely supplying candidates or is paired with -nointern; whether the trust anchors are independently configured; whether the successful -signer output is inspected instead of assuming the filename supplied on the command line was used; and whether negative controls demonstrate signer-not-found, content-failure and wrong-root behavior. OpenSSL's own CMS and CMS_verify() documentation supports those distinctions.
The precise operational answer is therefore:
-certfile A.pem alone does not restrict OpenSSL CMS verification to signer A. Without -nointern, OpenSSL may still locate and verify a different matching signer certificate embedded in the CMS message. -nointern restricts signer lookup to the supplied candidate source, but chain validation, content/signature verification and the application's independent A-only admission rule remain separate requirements.
Readers who want to strengthen the underlying cryptography, secure-data and delivery-review foundations can explore Refonte Learning's Cybersecurity & DevSecOps programme. Its published curriculum names Cryptography and Secure Data Management, Secure CI/CD Pipelines, DAST/SAST/IAST, IaC Security and Automation, OWASP and Threat Modeling Practices, Incident Response and Risk Mitigation, and Security Workflow Automation. The programme page does not establish that this exact CMS -certfile/-nointern laboratory is part of the curriculum, so the useful connection is the broader discipline: define the security boundary, test negative cases and review evidence before changing what an application accepts.
