ADR-0185: Capsule signer authenticity is certificate pinning, not PKI trust
Date: 2026-08-16 Status: Accepted Deciders: Adrian (project owner), Claude Opus 5, Codex (review, PR #1131) Refines: ADR-0017 (Trust Capsule cryptographic architecture)
Context
TrustCapsuleService.verify() returned signature_valid = None unconditionally,
with the comment "would need full PAdES validation". That made a signed
capsule and an unsigned one indistinguishable to a relying party — the one
distinction the field exists to make. PR #1131 made it a real verdict, and in
doing so had to answer a question ADR-0017 answered differently.
ADR-0017 specifies the production signing model as "an embedded signature and certificate chain" from an eIDAS qualified certificate issued by a Belgian CA. That is a statement about how capsules are signed. It does not say how a verifier decides that a signature it is looking at is ours, and the two are not the same question.
Three facts force the decision:
-
An intact signature attests to nothing about the signer.
pyhanko'sintact_signatureandvalid_docmdpare properties of the document: the digest still covers the bytes, and nothing forbidden was appended. Anyone can sign a PDF with their own key and pass both. A verifier that stopped there would report a forged capsule as valid. -
The deployments that exist do not have a qualified certificate. The pilot and every development environment sign with a self-signed certificate. Building a chain to a public root store would return "untrusted" for every capsule this system has ever produced — a control that is red on all genuine inputs, which ADR-0121 records as how a control gets switched off.
-
Revocation and chain-building need the network. ADR-0017's own goal is a "self-contained archive … no runtime dependency on TrustRelay to verify". A verifier that must reach a CRL or OCSP responder is not offline-verifiable, and a capsule opened five years from now — the AMLR retention horizon — would fail for reasons unrelated to its integrity.
Decision
signature_valid is True only when both hold:
- the signature is intact over the bytes it covers and the document-MDP policy
was not violated (
intact_signatureandvalid_docmdp); and - the signer's certificate is byte-for-byte the certificate this deployment
signs with —
capsule_signing_cert, compared on its DER encoding.
No chain is built, no root store is consulted, no revocation is fetched. The question answered is deliberately narrower than PKI trust: "did we seal this?" rather than "is this signer trusted by a public authority?"
When capsule_signing_cert is not configured the question is unanswerable and
the verdict is None — "could not check". It is deliberately not False (a
genuine capsule on a deployment that never configured verification is not a
forgery) and deliberately not True.
Authenticity is additionally bound to the manifest by a digest, not by reading fields out of rendered text. At seal time the PDF carries one row:
BINDING bind:v1:<64 hex> where hex = SHA-256(canonical({capsule_id,
case_id, decision, capsule_version,
tenant_id, merkle_root}))
Verification recomputes that value from manifest.json and requires the exact
token in the signed text. A signature over an untouched PDF therefore cannot be
reported valid for a manifest edited to name a different case, tenant or
decision.
Why a digest and not the six fields. The first implementation compared each
sealed field against the rendered text and took seven rounds of P1 review
findings, each closing a real hole and revealing the next: a whole-line match,
context-free tokens, folded underscores, erased whitespace, an empty value
skipping the check, a continuation running through the following row, and a
space landing exactly on an extraction seam. Every fix was correct. The shape
was not, and the reason is structural — rendering is lossy (CSS uppercases,
word-break: break-all inserts breaks, extraction adds and drops spaces), so
each comparison needed a tolerance; and TrustCapsuleManifest.case_id accepts
arbitrary strings, so every tolerance aliased two identities an attacker could
choose between. A hex digest has none of those properties: no legitimate
whitespace (so rejoining across seams is lossless), one value rather than a
label/value pair (so an attacker-controlled metadata row cannot satisfy it —
trust_capsule.html renders arbitrary artefact-metadata keys as field labels,
which is how a second CASE ID row became reachable), and no per-field
comparison to collide.
Three-valued, because there are three situations. True — the PDF carries
this manifest's digest. False — it carries a different one. None — it
carries no binding row, which is every capsule sealed before this ADR. None
is deliberately not False: a capsule sealed last year is not a forgery, and a
control that says otherwise gets switched off (ADR-0121). It is deliberately
not True either. The signature verdict still stands on its own for such a
capsule — it was really checked — and the caveat travels in signature_detail,
so a reader is never told the binding was established when it was not.
Consequences
Positive
- A signed capsule is now distinguishable from an unsigned one, which is the field's entire purpose.
- Verification is offline and stable over time: no network, no dependency on a CA still existing at the retention horizon.
- It works on a self-signed deployment, so the control is live everywhere rather than red everywhere.
- A forged capsule signed with an attacker's own key is refused, which document-integrity checks alone would accept.
Negative
- Certificate rotation invalidates historical verification. After rotating
capsule_signing_cert, capsules sealed with the previous certificate verify asFalse, notTrue— the pin names one certificate and the old one is no longer it. There is no configured set of historical signing certificates. This is the sharpest cost of the decision and it is not mitigated here; the remedy is a list-valued pin, deliberately deferred rather than half-built. - A revoked certificate still verifies. Revocation is not consulted, so a
compromised-and-revoked signing key produces capsules this verifier calls
authentic until the pin is changed. Rotating
capsule_signing_certis therefore the only revocation mechanism, and it is manual. - The verdict is meaningful only to a relying party who trusts this deployment's configuration. It is not an eIDAS qualified-signature validation and must not be described as one in any compliance artifact.
- Two notions of signer identity now exist in the repository: ADR-0017's production chain model and this pin. They are reconciled by scope — 0017 governs signing, this governs verification — but a reader who consults only one of them will draw the wrong conclusion, which is why this ADR exists.
- The binding no longer names which field moved. A digest over all six fields answers "this is a different manifest", not "the decision changed". That is a real loss of diagnostic precision, accepted knowingly: naming the field requires comparing fields one by one against rendered text, and seven review rounds established that this cannot be done soundly. The verdict is what a relying party acts on; the field name was convenience bought at the cost of correctness.
- Every capsule sealed before this ADR reports its binding as unknown. Their signatures still verify; what cannot be established is that the signature attests to the manifest shipped beside it. No fallback to the old per-field scan is provided, deliberately — a weaker path that an attacker chooses which capsule to submit to is not a compatibility measure, it makes the defect optional.
Neutral
timestamp_validremainsNone. ADR-0165 records that the capsule's RFC 3161 timestamp is detached rather than embedded, so there is no signature timestamp here to validate. Unchanged by this decision.- No schema change, no migration, no feature flag.
Alternatives Considered
Alternative 1: Full PAdES validation against a public trust store
- Build the certificate chain to a configured root store and check revocation, as ADR-0017's production model implies.
- Why rejected: every deployment that exists signs with a self-signed certificate, so this returns "untrusted" for 100% of genuine capsules today. A control that is red on all real inputs is one that gets disabled (ADR-0121), and it also breaks the offline-verification property ADR-0017 itself sets as a goal. This becomes the right answer once a qualified certificate is issued; the pin does not preclude it.
Alternative 2: Keep returning None
- Leave the field unimplemented and describe the capsule as Merkle-only.
- Why rejected: it is the status quo that made a signed and an unsigned capsule indistinguishable. It is also not free of claims — the architecture page asserted "signature validation is not performed", which by the time of this PR was describing the opposite of production. Doing nothing still required a documentation fix.
Alternative 3: Accept any intact signature, without pinning
- Report
Truewheneverintact_signatureandvalid_docmdphold. - Why rejected: it answers "was this modified after signing" and silently presents the answer as "is this authentic". An attacker signing their own forged capsule passes it. That is the false-clear ADR-0067 forbids, in the field a relying party is most likely to trust.
Alternative 4: Pin a list of accepted certificates now
- Configure current and historical signing certificates, closing the rotation gap immediately.
- Why rejected for this change, not on the merits: it is the right shape and it is recorded above as the remedy. It needs a configuration surface, a decision about who may add a certificate to the accepted set, and an audit record when they do — none of which belong in a PR whose subject is elsewhere. Building it half-way (a list with no governance over its contents) would create a wider acceptance set with no record of why each entry is in it.
Decision context:
- Latency: one DER comparison per verification, after a signature check that
already dominates. Not measured because the added work is a
bytesequality against a value read at startup. - Dependency surface: none added.
pyhankowas already a dependency and is already optional — its absence yieldsNone, neverTrue. - Debuggability: every branch returns a
detailstring naming what it checked and why it concluded what it did;signature_detailcarries it to the API and the card, so aFalseverdict is never bare. - Reversibility: a single function. Widening to a list-valued pin, or to full
chain validation, is a change to
_verify_pdf_signaturealone — no schema, no stored verdict to migrate. - Blast radius: one field on one response model plus the card that renders it.
Additive: nothing that previously read
signature_validbroke, because it previously readNoneandNoneremains a valid value. - Alternative considered: full PAdES chain validation — rejected because it is red on every capsule this system has produced (see Alternative 1).