Security Gate Review — ADR-005 PQC Hybrid Signing
This is a rendered copy of docs/security/005-pqc-hybrid-signing-review.md, read in place from the repository — see therecords index for how to read it. If this page and the source file ever disagree, the source file is correct.
Security Gate Review — ADR-005 PQC Hybrid Signing
| Field | Value |
|---|---|
| Issue | #248 — security gate for the ADR-005 PQC hybrid-signing surface |
| ADR | ADR-005: PQC Hybrid Signing |
| Merged work under review | #243 (noble ML-DSA-65 backend), #244 (native aws-lc-rs backend), #245 (hybrid construction), #246 (AKP JWKS), #247 (token-size posture) |
| Review date | 2026-07-18 |
| Method | 3 auth-specialist dimension reviews + adversarial re-verification of high/critical claims + synthesizer re-read of the merged files |
| Verdict | CONDITIONAL PASS — zero confirmed HIGH/CRITICAL; HYBRID_SIGNING_ENABLED stays default-OFF pending the pre-default-on checklist (§8) |
| Confirmed blockers | 0 |
| Findings | 4 MEDIUM, 4 LOW, 5 INFO (none gate-blocking) |
Note — 2026-07-27. This review’s premise that hybrid issuance “is not wired into live routes yet” (§2) no longer holds:
apps/auth-server/src/app/app.ts:243-252wiresmlDsaSeedandhybridSigningEnabled: trueinto the JWT plugin wheneverHYBRID_SIGNING_ENABLEDis set (#275, merged after this review’s 2026-07-18 date). Runtime security posture now does depend on this code once an operator sets that flag — the pre-default-on checklist (§8) is the thing to work through, not a hypothetical. The rest of this review’s findings and verdict are unaffected and were accurate as of its review date; this note marks a superseded premise, not a re-assessment.
1. Scope and gate question
This is the AC#1/AC#2 security gate for the merged PQC hybrid-signing crypto surface. The gate question is narrow and explicit:
Are there HIGH or CRITICAL crypto defects in the merged code that must block ever flipping
HYBRID_SIGNING_ENABLEDfrom its defaultfalsetotrueanywhere?
Surface reviewed (all merged on main):
libs/core/crypto/src/lib/backends/ml-dsa-65.ts— noble ML-DSA-65SignatureBackendlibs/core/crypto/src/lib/hybrid-signing.ts—signHybrid/verifyHybrid, detached-parallel constructionlibs/core/crypto/src/lib/hybrid-constants.ts— PQC header members, draft pinlibs/core/crypto/src/lib/keys.ts—MlDsaKey, seed-canonical private formlibs/core/crypto/src/lib/primitives.ts—SignatureBackendcontract +getSignatureBackendallowlistlibs/core/crypto/src/lib/signing.ts—sign()protected-header merge,verify()libs/core/crypto-native/src/{index.ts,lib.rs,addon.ts},Cargo.toml,Cargo.lock— nativeaws-lc-rsbackendlibs/server/jwt/src/lib/hybrid-jwt-service.ts— hybrid access/id-token wrapperslibs/server/jwt/src/lib/jwks.ts— AKP JWK export (public-only)libs/fastify/plugins/jwt/src/lib/fastify-plugin-jwt.ts— boot-time ML-DSA public-key derivationlibs/server/config/src/lib/schemas/crypto.ts—HYBRID_SIGNING_ENABLED+ posture couplingapps/auth-server/src/app/app.ts,apps/auth-server/src/config/env.ts— boot wiring
2. Established design context (not re-litigated)
Per the ADR-005 amendments (#245), the hybrid construction is a detached-parallel signature and deliberately not the strict LAMPS/prabel composite: the token is an ordinary Ed25519 compact JWS, the ML-DSA-65 signature is detached in a separate pqcSignature field, and pqc_alg/pqc_kid are non-critical protected-header members to preserve stock-JOSE-verifier compatibility (AC#2). Downgrade resistance within a single bearer token is, by documented design, a verifier-policy control (requirePqc), not a cryptographic one. This review assessed whether that documented mitigation actually holds in code; it did not re-propose the architecture. HYBRID_SIGNING_ENABLED defaults OFF and hybrid issuance is not wired into live routes yet, so no runtime security posture depends on this code today.
Update (2026-07-31): only the default-OFF half still holds. #275 wired live hybrid issuance and PQC-aware verification into
/oauth/token,/auth/loginand/oauth/introspect(apps/auth-server/src/app/helpers/hybrid-token.ts) — as §8 item 4 of this document already records as DONE. Any deployment that flips the flag therefore does rest a real runtime security posture on this surface, so the residual §8 items are now pre-conditions for enabling it, not merely for defaulting it on.
Superseded 2026-07-27 — see the note at the top of this document. #275 wired hybrid issuance into live routes after this review’s date; with
HYBRID_SIGNING_ENABLEDset, runtime posture does depend on this code.
3. Methodology
Three auth-specialist reviewers each took one dimension of the mandated five tasks, reporting findings with file:line and a concrete attack/failure path:
- Construction / downgrade / tamper / confusion — the detached-parallel JWS carrier and its resistance to strip, mix-and-match, and algorithm-confusion attacks (#248 task 2, 3a).
- Key lifecycle / backend swap — key generation, storage, export, JWKS, rotation, and the noble↔native backend-swap safety property (#248 task 1, 3b).
- Supply chain / standards / config gate — native-binding provenance and pinning, AKP/JOSE draft conformance, and the
HYBRID_SIGNING_ENABLEDconfig gate (#248 task 4, 5).
Every claim that could plausibly reach HIGH/CRITICAL was re-tested against a real attack path and against the test suites (hybrid-signing.test.ts, crypto-native/src/index.test.ts). Process note: dimension 3’s first automated pass failed on a tooling error (structured-output cap) and was re-run to completion as a focused auth-specialist review; its findings are fully incorporated below.
4. Threat model and gate criterion
- CRITICAL — key/secret exposure, or a trivially forgeable/bypassable signature.
- HIGH — exploitable downgrade/confusion/tamper under a realistic threat model, or a supply-chain RCE vector.
- MEDIUM — defense-in-depth gap or spec non-conformance without a direct exploit.
- LOW / INFO — hardening.
Only HIGH/CRITICAL block the gate.
5. Positive security confirmations (independently verified)
- Signing-input binding. Both signatures cover a byte-identical preimage: the ML-DSA signature signs
extractJwsSigningInput(token), literally sliced from the jose-produced token (hybrid-signing.ts:70-76,95-96), and that preimage includes the Ed25519-signedpqc_alg/pqc_kidheader. Any header/payload mutation breaks both signatures; neither half can be swapped (hybrid-signing.test.tsmix-and-match and single-byte-mutation both rejected). - Algorithm confusion closed.
verifyHybridappliesalgorithms: ['EdDSA']after the options spread (hybrid-signing.ts:123), so a caller cannot injectnone/HS. The single PQC algorithm is checked against a constant and fails closed (:136-140).MlDsaKey.algis a structural dispatch discriminant, so a key can only be used with its own algorithm. requirePqc=trueprevents the strip-downgrade. An absent/empty PQC signature is rejected (hybrid-signing.ts:127-132), tested inhybrid-signing.test.ts.- No verify oracle. Both backends normalize a forged signature (
false) and a malformed input to the sameCryptoVerificationError('invalid')(ml-dsa-65.ts:91-108; nativeindex.ts:79-90backed by Rustlib.rs:60-67returningOk(false)). Verify uses only public material. - No key/secret exposure. Private keys are non-extractable by default (
keys.ts:66);exportKeythrows on a non-extractable private key; the canonical seed never reaches a public path. JWKS is public-only and type-guarded (jwks.ts:48-57, nopriv/d). - Supply chain has no live RCE vector.
aws-lc-rsis exact-pinned=1.17.3with a committedCargo.lock; no.node/target/is committed (gitignored,git ls-files '*.node'empty); the napi loader resolves relative to the module (addon.ts:14,26-29) — no cwd/env/NODE_PATH/bare-specifier injection.aws-lc-sys 0.43.0uses pregenerated bindings (nobindgen/libclang). - Config gate fails fast and defaults off.
HYBRID_SIGNING_ENABLED=truewithoutSIGNING_ALGORITHM_MODE='ed25519+ml-dsa-65'and a key aborts boot (crypto.tssuperRefine); thePQC_SELF_CONTAINED_ACKguard isz.enum-typed and fail-closed (a mis-cased value is a loud parse error). Hybrid cannot be half-configured into silent classical-only signing.
6. Findings
All findings are non-gate-blocking (0 HIGH/CRITICAL). IDs are referenced by the pre-default-on checklist (§8).
| ID | Sev | Title | Location |
|---|---|---|---|
| F1 | MEDIUM | pqcSignature | hybrid-signing.ts |
| F2 | MEDIUM | MlDsaKey.material() has divergent semantics across noble/native backends — the key object is not backend-portable | crypto-native/src/index.ts:48 |
| F3 | MEDIUM | AKP/ML-DSA-65 JWK shape is pinned to the wrong (and WG-superseded) IETF draft | hybrid-constants.ts:23, jwks.ts:35 |
| F4 | MEDIUM | Native-binding provenance gap: the deferred prebuild matrix has no reproducible-build / attestation / cargo audit story | crypto-native/Cargo.toml:16, addon.ts |
| F5 | LOW | pqc_kid header | hybrid-signing.ts |
| F6 | LOW | sign() header-merge order lets a caller header override the canonical alg or inject crit | signing.ts:70 |
| F7 | LOW | Runtime algorithm allowlist is bypassed at its live call sites (hardcoded ['ML-DSA-65'] instead of the operator-enabled set) | fastify-plugin-jwt.ts:87, hybrid-signing.ts:96,144 |
| F8 | LOW | Config gate validates ML-DSA seed presence but not base64url decodability / 32-byte length | crypto.ts:37 |
| F9 | INFO | Rotation unimplemented; kid uniqueness across OKP/AKP entries unconstrained | fastify-plugin-jwt.ts:135 |
| F10 | INFO | Transient ML-DSA private key not zeroized after boot-time public-key derivation | fastify-plugin-jwt.ts:88 |
| F11 | INFO | Hybrid path hardcodes the noble backend, bypassing SIGNING_ALGORITHM_MODE and the native backend | hybrid-signing.ts:1 |
| F12 | INFO | unstable aws-lc-rs feature on the signing path — acceptably documented and exact-pinned | crypto-native/Cargo.toml:14 |
| F13 | INFO | pqc_alg / pqc_kid are unregistered private JOSE header parameters | hybrid-constants.ts:26 |
Notable MEDIUM detail
- F1 (downgrade-as-policy is not yet issuer-signed).
verifyHybrid’s downgrade decision keys on the presence of the transportpqcSignature, not on the Ed25519-signedpqc_algin the protected header. Today this is sound becauserequirePqcis the documented control and issuance is unwired; but when live verification lands (#249), a signedpqc_algshould be treated as binding so downgrade resistance becomes an issuer-signed control rather than a per-call flag. Tracked as checklist item 3. - F3 (wrong draft pin).
PQC_JOSE_COMPOSITE_DRAFT = 'draft-prabel-jose-pq-composite-sigs-02'is designated as the draft-churn firewall, butkty:'AKP'/pub/ theML-DSA-65alg spelling actually come fromdraft-ietf-cose-dilithium(“ML-DSA for JOSE and COSE”); the composite-sigs draft describes the concatenated composite QAuth deliberately does not implement, and the individual-prabel-…-02pin is stale versus the WG-adopteddraft-ietf-jose-pq-composite-sigs. The emitted wire shape happens to match cose-dilithium, so there is no live interop break — but the safeguard meant to catch draft churn points a future reviewer at the wrong document. Tracked as checklist item 1. RESOLVED (#274). The constant was replaced byPQC_JOSE_MLDSA_SPEC = 'RFC 9964'— during remediation the cose-dilithium draft was found to have been published as RFC 9964 (Standards Track, May 2026; final draft revision-11), so the AKP shape andML-DSA-65spelling are now pinned to a stable, IANA-registered standard rather than a draft.PQC_JOSE_ALG_POLICY_SPEC = 'RFC 9864'records the fully-specified-algorithm policy. The misleading composite constant was removed (nothing referenced it; QAuth implements no composite path). The emittedAkpJwkwas re-verified against RFC 9964:kty/alg/pubREQUIRED and present,privabsent, and a test now asserts the exact member set.
7. Backend-swap and key-lifecycle summary
The noble↔native swap is interoperable at the seed/wire level (same 32-byte seed → identical keys; signatures cross-verify — proven by the #244 interop tests), and verification is public-material-only with a single error vocabulary, so there is no algorithm-confusion or silent wrong-key-accept across backends. The one latent footgun (F2) is that a MlDsaKey object produced by one backend is not guaranteed portable to the other’s sign() because material() differs (seed vs expanded secret); this is invisible today (one backend is selected per process) but must be unified or backend-tagged before both are wired live.
8. Pre-default-on checklist
HYBRID_SIGNING_ENABLED MUST remain default-OFF until every item is satisfied. None block the merged surface; each blocks the first default-on deployment.
Correct the JOSE pin [F3].DONE (#274). Pinned toPQC_JOSE_MLDSA_SPEC = 'RFC 9964'(the published cose-dilithium successor) andPQC_JOSE_ALG_POLICY_SPEC = 'RFC 9864'; the composite-sigs constant was removed (re-add under its own name, citingdraft-ietf-jose-pq-composite-sigs, only if a composite path is later added). The emittedAkpJwkwas diffed against the RFC 9964 example JWK and matches, with a test asserting the exact member set. Still open: confirming an independent ML-DSA-in-JOSE verifier resolves/.well-known/jwks.json— this needs a third-party verifier and a running server, so it remains a pre-default-on manual step.Wire hybrid issuance + a PQC-aware verify path into the live auth-server routes (#247 delivery, #249 introspection/mcp-guard).DONE (#275). The token endpoint issues hybrid tokens whenHYBRID_SIGNING_ENABLEDis on; the detached ML-DSA-65 signature is stored per-jtiand returned from/oauth/introspectaspqc_signature(reference delivery).jwtUtils.verifyHybridAccessTokenis the PQC-aware verify path.Make downgrade resistance issuer-signed [F1].DONE (#275).verifyWithHeader()surfaces the Ed25519-verified protected header (signing.ts);verifyHybridtreats a present signedpqc_algas BINDING regardless ofrequirePqcand cross-checkshybrid.pqcAlgagainst it. A token with no signedpqc_algis rejected underrequirePqc=true, and any unvouched-for attached signature is ignored. Covered by the strip-with-requirePqc=falserejection test inhybrid-signing.test.tsand end-to-end inhybrid-token.test.ts.Bind ML-DSA key resolution to the signedDONE (#275).pqc_kid[F5]HybridVerifyKey.mlDsaaccepts anMlDsaKeyResolverinvoked with the SIGNEDpqc_kidread from the verified header, failing closed when nothing resolves. The unsignedHybridSignedToken.pqcKidtransport field was dropped — nothing depended on it.- Route the PQC backend through
getSignatureBackend('ML-DSA-65', enabledSignatureAlgorithms)[F7/F11] at the hybrid call sites and the plugin boot derivation, threading the operator-enabled set fromcryptoEnv; remove the hardcoded['ML-DSA-65']. - Unify
MlDsaKey.material()across backends [F2] (both sign fromseed(), or both store the expanded secret), or tag the backend onMlDsaKeyand enforce it insign(); add a negative test that a cross-backend key object throws. - Lock the
sign()header-merge invariant [F6]: merge as{ ...header, alg }so the canonicalalgalways wins, and throw ifoptions.headercontains a reserved member (alg,crit,b64). - Stand up a trustworthy native-build channel [F4] before enabling the native backend anywhere: reproducible CI build of the
.nodefrom pinned source, published checksum + provenance attestation, load-time integrity verification, andcargo audit+cargo deny/vetin CI forcrypto-native. — Addressed by #277 (.github/workflows/crypto-native.yml,rust-toolchain.toml,deny.toml,src/addon-integrity.ts): pinned toolchain + lock-enforced build, linux determinism probe,sha256sidecar + SLSAattest-build-provenance, pre-dlopenchecksum verification defaulting toenforce, andcargo audit/cargo denyover the crate and its cmake/cc build surface. Residual: cross-platform determinism (macOS/Windows) and the attestation step itself are only exercised by a real CI run; the full napi triple set is still not cross-compiled. - Validate the seed at the config layer [F8]: confirm
JWT_MLDSA_PRIVATE_KEY(_PATH)base64url-decodes to exactly 32 bytes incryptoEnvSchema. - Implement rotation before publishing >1 key [F9/F10]: JWKS resolution keyed on
(kid, alg/kty)notkidalone, distinct kids across OKP/AKP, retired keys published under their own kid; zeroize the transient boot seed after public-key derivation. - Publish an operator runbook: set
requirePqc=truewherever PQC verification is expected, and document thatrequirePqc=falseis an explicit accept-classical (Ed25519-floor) posture, not a silent downgrade.
9. Verdict
CONDITIONAL PASS. Zero confirmed HIGH/CRITICAL defects survived adversarial re-verification across all three dimensions: no key/secret-exposure path and no trivially forgeable or bypassable signature exists in key generation, storage, JWKS export, the detached-parallel construction, the backend seam, or the supply-chain surface. The capability is cryptographically sound. It is conditional, not a full pass, because named preconditions (§8) remain before HYBRID_SIGNING_ENABLED may default ON anywhere — chiefly: hybrid issuance/verification are unwired; the JOSE draft pin must be corrected and reconfirmed; downgrade resistance must become issuer-signed at the point live verification lands; and the native-binding provenance channel must be established. Per AC#2, with zero open HIGH/CRITICAL findings, the merged surface is cleared to remain in the tree with the flag default-off; the checklist gates the first default-on deployment.
Generated by the #248 security-gate review (3 auth-specialist dimensions + adversarial verification). Findings F1–F13 are hardening/pre-default-on items, not merge blockers.