ADR 0007: Crypto-agility alg dispatch
July 15, 2026 · View on GitHub
- Status: accepted. Documents an already-shipped mechanism (
trust_pack.py, Welle 1 Finding 08 / commitPB-2026-0715-08, 3.2.2) and generalizes it as a named, reusable pattern for the next consumer, mirroring how ADR 0002 named a primitive that already existed indecision.pybefore generalizing it. This ADR changes no shipped behavior. - Date: 2026-07-15 (decision date; commit date live)
- Deciders: proofbundle maintainer (b7n0de)
- Builds on: ADR 0006 (anchor longevity —
renewal.py's_SIG_ALGS/_verify_ats_signatureis where this pattern was first built, for the B3↔B5 renewal-signature migration); ADR 0003 (post-quantum payload signatures — the deferred design this pattern would inform if/when a hybrid payload signature is chosen, see §Consequences)
Context
trust_pack.py (trust-pack/v0.1, EXPERIMENTAL 3.2.0 O2) is a TUF-inspired root of trust: each
role (root, evalIssuers, decisionMakers, outcomeExecutors, timeAuthorities, witnesses) maps to a
set of key ids and a signature threshold. Welle 1's Finding 08 (PB-2026-0715-08, commit
0e2e60d) closed a real gap: the root/rotation keys had no declared signature algorithm, so a
future PQ-migrated root key had nowhere to say what it was, and — the sharper risk — nothing
stopped a hybrid key from being satisfied by only its (weaker, quantum-vulnerable) classical leg.
This is the second time this project has needed exactly this shape. ADR 0006 built it first,
for renewal.py's ArchiveTimeStamp time-authority signatures (_SIG_ALGS = ("ed25519", "hybrid-ed25519-mldsa65", "mldsa65"), _verify_ats_signature, the algorithm bound into
_ats_content so it cannot be relabeled after signing). The Finding 08 fix built the same shape
again for trust_pack.py's root keys, independently, because ADR 0006 documented it as a solution
for the renewal chain specifically, not as a project-wide pattern. This ADR closes that gap: it
names the pattern once, so a third consumer (the next PQ extension point — see §Consequences)
reuses it instead of re-deriving it, or worse, inventing a subtly different and weaker variant.
A note on the two different ML-DSA parameter sets in this codebase, because a reviewer will
ask: trust_pack.py / renewal.py use ML-DSA-65 (_KEY_RAW_LEN["mldsa65"] = 1952-byte public
key, FIPS 204 Category 3) for the root-of-trust / time-authority use case, while checkpoint.py's
witness cosignatures (shipped since v1.3, STABLE, C2SP type 0x06) use ML-DSA-44
(_MLDSA44_PUB_LEN = 1312 bytes, FIPS 204 Category 2) for the witness-cosignature use case. This
is deliberate, not drift: C2SP's own spec names ML-DSA-44 as its SHOULD for witness deployments (a
witness signs many checkpoints, so the smaller/faster parameter set is the documented tradeoff),
while a root of trust and a decades-scale time-authority signature reasonably choose the higher
security margin. checkpoint.py's ML-DSA-44 path is also architecturally SEPARATE — it imports
cryptography.hazmat.primitives.asymmetric.mldsa directly in its own _mldsa_module() capability
probe, not through pqsig.py — which is why checkpoint.py stays STABLE (docs/AUDIT_SCOPE.md)
even though pqsig.py (the module trust_pack.py and renewal.py share) is EXPERIMENTAL.
Decision
Formalize the per-key/per-signer crypto-agility alg dispatch as the shape any future
algorithm-migratable signature surface in this codebase uses:
- A key or signer entry declares its algorithm explicitly,
alg∈{"ed25519" (default), "mldsa65", "hybrid-<classical>-<pq>"}(today:"hybrid-ed25519-mldsa65"). An absentalgdefaults to"ed25519"— every pre-agility artifact (atrust-pack/v0.1pack with noalgfield, an unsigned/legacyArchiveTimeStamp) keeps verifying unchanged. This is additive, never a silent upgrade of an existing key's assumed strength. - The
alglabel lives INSIDE the bytes the signature covers, never in an unsigned envelope field read before verification.trust_pack.py'salgis a field of the signed predicate (keys[kid].alg);renewal.py'ssig_algis folded into_ats_content(...)before signing. This is what closes the JWT-alg-header class of downgrade attack: relabeling the algorithm after the fact invalidates every signature over the artifact, because the label was part of what was signed. There is no separate "trust the label, then dispatch" step where a label could be swapped without breaking the signature. - A hybrid entry requires BOTH legs to independently verify — never an OR.
trust_pack.py's_verify_signature_for_algandrenewal.py's_verify_ats_signatureboth fail closed (returnFalse) if either the classical (sig/publicKey) or the PQ (sigPq/publicKeyPq) leg is missing, malformed, or does not verify. A hybrid key's whole purpose — surviving a break of either algorithm alone — requires conjunctive verification; an either-leg design would let an attacker who breaks only the older/weaker leg forge the hybrid signature, which defeats the reason to add the PQ leg at all. - A hybrid entry's PQ leg is a distinct, equally-sized wire field, never encoded inside the
classical signature's bytes:
sigPqalongsidesig(mirrorspublicKeyPqalongsidepublicKey). Both are required together —trust_pack.py's validator rejects apublicKeyPqpresent withoutalg: hybrid-...and rejects a hybrid entry missingpublicKeyPq. - Verification counts DISTINCT decoded key material, never keyId/name labels, before it
counts toward a threshold or quorum.
trust_pack.py::verify_trust_packdedups root signers by the decodedpublicKeybytes; the same defense already exists incheckpoint.py::witness_quorum(documented there as closing a Sybil class: one physical key registered under many names must count once, not once per name). This ADR names it as a general rule for any future threshold/quorum surface, not acheckpoint.py-specific fix. - An asymmetric fail-closed boundary, by design, not by oversight. An unrecognized
algon the SIGNED, attacker-influenceable predicate field (keys[kid].alg) is a hard validation error —validate_trust_pack_predicaterejects the whole pack. An unrecognizedalgon caller- supplied, out-of-band trust material (prev_root_keys, the previous pack's root role a relying party passes in for rotation vouching) safely defaults to"ed25519"rather than raising, because that value never came from the artifact under review — it is the relying party's own configuration, outside the predicate's schema gate. Conflating these two would either make the fail-closed rule too strict (breaking a legitimate caller integration) or too loose (accepting an attacker-chosen algorithm label inside signed bytes); keeping them distinct is the point.
Options considered (why not the alternatives)
| Option | Shape | Why not chosen |
|---|---|---|
Unsigned alg header (JWT-style, e.g. a top-level envelope field read before verification) | dispatch on a field outside the signed bytes | the exact algorithm-confusion class this project's own SPEC §4a Ed25519 edge-case work and the _ats_content/predicate-field placement above are designed to avoid; a label outside the signature can be swapped without breaking anything |
| Either-leg hybrid (classical OR PQ satisfies) | accept if either signature verifies | defeats the purpose of a hybrid leg: an attacker only needs to break the weaker of the two algorithms, so the "defense in depth" a hybrid design promises does not hold |
| Separate out-of-band algorithm registry (a detached file mapping keyId → alg) | key material and algorithm declared in different artifacts | extra indirection with no benefit over an inline declaration, and reopens exactly the "label lives outside the signed bytes" problem from the first row if the registry itself is not bound to the pack |
Per-key alg inside the signed artifact, hybrid-both-legs required, dedup by key material (what ships) | this ADR's decision | closes the downgrade path, reuses a pattern proven in two independent consumers (renewal.py, trust_pack.py), and composes with the existing Sybil defense in checkpoint.py |
Consequences
- No wire-format break. Every pre-agility
trust-pack/v0.1pack (noalgfield) and every unsigned/legacyArchiveTimeStamp(sig_alg = "") keeps verifying exactly as before. - The next PQ extension point has a starting shape. ADR 0003 (deferred payload-level PQ
signatures) names Option B — an Ed25519+ML-DSA-44 hybrid on the bundle's own
signatureblock — as the leading future candidate, still blocked on a wire-format design. When that work resumes, it should reuse this ADR's three load-bearing rules (label inside the signed bytes, hybrid requires both legs, dedup by key material before any threshold) rather than re-deriving them a third time. This ADR does not itself extend to the bundle's top-levelsignatureblock — that remains ADR 0003's territory, unchanged and un-implemented by this document.renewal.py'sArchiveTimeStampsignatures andtrust_pack.py's root keys stay the only two consumers of this dispatch shape today. - Scope stays EXPERIMENTAL.
trust_pack.pyandrenewal.pyremain EXPERIMENTAL (docs/AUDIT_SCOPE.md); this ADR documents their shared internal contract, it does not graduate either module to STABLE.checkpoint.py's separate, already-STABLE ML-DSA-44 witness path is unaffected — it does not use this dispatch shape and this ADR does not change it. - The next implementer has a starting point, not a design discussion. Matching ADR 0003's own closing note: any change here still needs its own adversarial review before landing (matching how the Finding 08 fix and ADR 0006's B3↔B5 wiring were both reviewed) — this ADR records the contract, it does not exempt a future change from review.