Engineering · 2026-07-10
Cryptographic Agility as a Protocol Property: Designing Identity Wire Formats That Survive Algorithm Deprecation
By J. W. Bouckaert
The library trap
Most treatments of "cryptographic agility" describe a library problem: keep the algorithm behind an interface, pass in an identifier, swap implementations at build time. That framing survives about as long as the first algorithm deprecation. The moment a signature scheme is retired, every relying party that ever verified an assertion under the old scheme has to be reasoned about—not only the ones you control today.
Agility is a property of the bytes on the wire.
If the wire format does not carry an explicit algorithm identifier, a version tag, and a rule for negotiating between them, then the "agile" library is running underneath a rigid protocol, and the protocol wins. This is how SHA-1 lingered in TLS for a decade past its obituary, how RSA-1024 X.509 chains outlived the CA/Browser Forum's own deadlines, and how JWS alg: "none" became a CVE class of its own.
Identity assertions have a longer verification tail than TLS sessions and a shorter tolerance for ambiguity than certificate chains. A W3C Verifiable Credential issued today may be presented to a verifier in 2036. That verifier will not have the same algorithm registry, the same trust roots, or in many cases the same operator. The only durable contract is the wire format.
This article specifies the pattern PasskeyBridge uses for that contract. It is agnostic to any single credential shape—the same suite-ID and envelope discipline applies to SD-JWT-VC, W3C VC-JWT, COSE_Sign1 mdoc assertions, and internal ITRaaS attestations.
Four questions an agile protocol answers
A cryptographically agile identity protocol has to answer four questions on every wire:
- Which suite signed this? The verifier must be able to identify the exact algorithm(s), parameter set, and canonicalization used, without pattern-matching on payload shape.
- Is that suite still acceptable? The verifier must be able to say "yes, no, or conditional" against a policy the issuer does not control.
- What is the migration path if it is not? The wire must carry enough information for a verifier to fall through to a secondary signature, request a re-issuance, or decline cleanly.
- Can the answer to (1) be forged? An attacker must not be able to talk a verifier into accepting a weaker suite than either party would have chosen on their own.
Every classical failure mode—BEAST, FREAK, Logjam, ROBOT, the JWS alg confusion class—maps to one of those four questions being answered wrong on the wire. The specification below is designed against the same class of adversary.
The four-layer envelope
Every signed identity assertion PasskeyBridge produces sits inside a nested envelope with four layers. Each layer answers exactly one of the questions above.
┌─────────────────────────────────────────────────────┐
│ L1 Suite ID (agile identifier, integer) │
│ L2 Signature Stack (one or more suites) │
│ L3 Binding (transcript + downgrade proof) │
│ L4 Payload (canonical claims, algorithm-blind) │
└─────────────────────────────────────────────────────┘
L1 answers "which suite." L2 carries the signatures. L3 answers "was this the suite both parties would have chosen." L4 is the actual assertion—it never mentions an algorithm.
Keeping L4 algorithm-blind is the single most important discipline. The moment claim content depends on the signing algorithm, the migration path is closed off: you cannot re-sign with a new suite without rewriting semantics.
Layer 1: Suite identifiers are numbers
Named algorithms are how humans discuss cryptography. Numbers are how protocols carry it. Every suite the platform accepts is registered as a 16-bit integer in a governance-controlled table.
| Suite ID | Family | Signature | Canonicalization | Status |
|---|---|---|---|---|
| 0x0001 | ES256 | ECDSA P-256 (RFC 7518) | JCS (RFC 8785) | Legacy—verify only |
| 0x0010 | Hybrid A | ES256 + ML-DSA-65 | JCS | Default |
| 0x0011 | Hybrid B | ES384 + ML-DSA-87 | JCS | Enterprise |
| 0x0020 | PQ-only | ML-DSA-65 | JCS | Phase 3 target |
| 0x0021 | PQ-only | ML-DSA-87 | JCS | Phase 3, Enterprise |
| 0x0030 | Stateless HB | SLH-DSA-256s | JCS | Long-lived credentials |
| 0x00FF | Reserved | — | — | Test-vector only |
Three properties make this table load-bearing:
Integer suite IDs. A suite ID is a fixed-width integer rather than a JOSE alg string. This eliminates an entire class of parser confusion—no case-folding, no whitespace, no alg: "none" equivalents, no ambiguity between ES256 and ES256K. A verifier that receives an unknown 16-bit integer knows it does not know what to do; a verifier that receives an unknown alg string too often guesses.
One suite, many parts. The identifier binds the signature algorithm and the canonicalization and, for hybrid suites, the ordering and combination rule. Two signatures over the same payload with the same algorithm but different canonicalizations are, correctly, different suites.
Status is a runtime attribute. Legacy—verify only means the issuer will not produce new assertions under 0x0001, but the verifier will accept an existing one if the assertion's issued_at predates the deprecation date carried in the platform's suite policy. Deprecation is a wire event with a timestamp.
The registry is versioned and served over the same DID-web trust root as the platform's issuer keys, so a verifier can pin a specific registry generation the same way it pins a key.
Layer 2: The signature stack
Layer 2 is a length-prefixed array of (suite_id, signature_bytes) tuples. For the default hybrid suite (0x0010), the stack contains exactly two entries in a fixed order:
[
(0x0001, ecdsa_p256_signature_bytes_64),
(0x0020_inner, mldsa65_signature_bytes_3309)
]
The inner suite IDs are component IDs—0x0001 for the ES256 half, 0x0020_inner for the ML-DSA-65 half—and only appear inside a stack. The outer stack itself is bound to the hybrid suite ID 0x0010, which fixes the combination rule: both signatures verify against the identical L4 payload bytes, in the exact order shown, or the assertion is rejected.
This is the same construction described in the hybrid signature scheme article, lifted from a signing convention into a wire-format contract. The lift matters. When ML-DSA-65 is eventually deprecated—say in 2036—the issuer switches to suite 0x0011 (ES384 + ML-DSA-87) or 0x0020 (PQ-only). Existing verifiers that only understand 0x0010 continue to verify assertions issued before the deprecation date, using the exact bytes they already have. No re-issuance is required for historical assertions to remain valid.
The stack is not open-ended. A hybrid suite fixes both the count and the order of components. This closes off the "add a weak signature to a strong one" attack—an adversary cannot append a compromised Ed25519 signature to an ML-DSA-only assertion and have any verifier accept it, because the outer suite ID would still say "PQ-only," and PQ-only accepts a stack of length one only.
Layer 3: The binding transcript
Every assertion carries a binding block:
binding {
registry_version: uint32, // suite table generation
suite_id: uint16, // outer suite (matches L2)
offered_suites: uint16[], // what the issuer offered
selected_suite: uint16, // what the verifier accepted (echoed)
issued_at: iso8601,
nonce: 32-byte QRNG value,
transcript_hash: 32-byte SHA-256
}
The transcript hash is computed over the concatenation of every prior message in the issuance handshake, in order, prefixed by the same registry version and the offered-suites list. This is the same pattern TLS 1.3 finalized as the Finished computation after a decade of downgrade attacks against earlier TLS.
The property this buys: if an active attacker strips 0x0010 from the offered list and coerces both sides into 0x0001 (Ed25519-only), the transcript hash computed by the issuer will not match the transcript hash the verifier reconstructs from the modified handshake. The Ed25519 signature will verify against the bytes it signed—but the binding block will not match the verifier's independent reconstruction, and the assertion is rejected.
The binding block is signed as part of L4 and never on its own. This means an attacker cannot substitute a benign binding block onto a real signature; the binding is inside the signed payload, and any tampering breaks the outer signature.
Layer 4: Algorithm-blind claims
The claims themselves say nothing about algorithms:
{
"assertion_type": "identity_attestation",
"subject_hash": "sha256:a1b2c3...",
"issuer_did": "did:web:api.passkeybridge.io:tenants:t_abc",
"issued_at": "2026-07-10T14:30:00.000Z",
"expires_at": "2027-07-10T14:30:00.000Z",
"claims": { "sim_signal_bound": true, "passkey_bound": true },
"binding": { /* L3 block, verbatim */ }
}
There is no alg field. There is no kid that encodes the algorithm. The subject hash is a SHA-256, but the payload does not care whether the signature over it is ES256, ML-DSA-65, or SLH-DSA-256—it only cares that the signature covers exactly these bytes, canonicalized with JCS.
This is the property that makes the whole scheme survive an algorithm swap. In 2036, if ML-DSA-65 is deprecated, the platform re-signs new assertions under 0x0011 or 0x0020, updates the registry version in the binding block, and every existing verifier continues to interpret the L4 claims exactly the way it always has. The only new code path a verifier needs is the parser for the new suite ID.
Downgrade resistance in practice
The four-layer envelope resists three specific classes of downgrade:
Rollback via suite substitution. An attacker in the middle of an issuance handshake rewrites the issuer's offered-suites list to remove PQ suites. Blocked by the transcript hash: the verifier reconstructs the handshake independently and detects the mismatch.
Envelope stripping. An attacker takes a hybrid-signed assertion, discards the PQ signature, and presents a stack containing only the Ed25519 half. Blocked by the outer suite ID: the outer ID says 0x0010 (hybrid), the stack has length 1, and 0x0010 requires length 2. The verifier rejects on structure before evaluating any signature.
Registry substitution. An attacker pins an old suite registry that still lists 0x0001 (Ed25519-only) as an acceptable default. Blocked by the registry_version inside the binding block: verifiers refuse to fall back to a registry older than the freshest one they have observed for the issuer's DID, per the same trust-on-first-use discipline used for did:web resolution.
Table:
| Attack | Layer that blocks it | Failure mode |
|---|---|---|
| Suite substitution mid-handshake | L3 (transcript hash) | Binding mismatch |
| Envelope stripping | L2 (stack arity) | Structural reject |
| Registry rollback | L3 (registry_version) | Policy reject |
alg: none equivalent | L1 (numeric IDs) | Not representable |
| Cross-algorithm confusion | L1 (suite ≠ algorithm) | Unknown suite reject |
Nothing here is exotic. TLS 1.3 codified the same discipline after two decades of losing to earlier attacks. The identity ecosystem has not yet paid that tuition; the specification above is a way to skip it.
Timeline of suite lifecycle
Deprecation runs on a timeline. Each suite moves through five states:
Proposed ──► Available ──► Default ──► Deprecated ──► Verify-only ──► Retired
│
└── ~5 years typical
- Proposed—Suite ID reserved, no production traffic. Test vectors published.
- Available—Issuers may opt in. Verifiers must accept.
- Default—Issuers default to this suite for new assertions.
- Deprecated—Issuers stop producing new assertions under this suite. Existing assertions remain valid.
- Verify-only—Verifiers still accept, but log a policy warning. Renewals must upgrade.
- Retired—Verifiers reject. All assertions must have been re-issued under a newer suite before this date, or the credential holder must re-verify.
The lifecycle dates for every suite are published in the registry, machine-readable, and signed. Verifiers evaluate acceptability against the assertion's issued_at—so an assertion signed in 2027 under 0x0010 remains verifiable in 2035 even if 0x0010 was deprecated in 2032, as long as 2035 is still inside the verify-only window.
Governance: Who adds a suite
A suite table is only as trustworthy as the process that changes it. The PasskeyBridge suite registry is governed by four rules:
- A new suite requires a public reference implementation and an independent security review. The review is published alongside the suite proposal, and its author is named.
- A new suite is Proposed for a minimum of 90 days before it can transition to Available. This is the window during which relying parties can pin against it in test.
- A suite can only be Deprecated with published cause. The cause is a specific event: a cryptanalysis result, a NIST guidance change, a CA/Browser Forum decision, or a documented implementation flaw. "We would like to move on" is not cause.
- Retirement requires an announced date at least 18 months in the future, tracking the same Harvest Now, Decrypt Later window as the underlying threat model. Enterprise tenants can request a longer window via contract.
These rules are enforced in the same code path that emits the registry manifest. The manifest is SOC 2 audited and every change is recorded in the immutable audit trail with the zero-PII discipline applied to every other change.
Interop: JOSE, COSE, and mdoc
The four-layer envelope is a schema. Existing container formats can carry it with minor adaptations:
- JOSE / JWS. The suite ID goes in a protected header (
"pb_suite": 16), the signature stack becomes a JSON array under"pb_sigs", and the binding block sits inside the payload. The JOSE"alg"header is set to the component algorithm of the first stack entry, purely for library compatibility, and is never trusted—verifiers ignore"alg"and readpb_suite. - COSE_Sign_Tagged. The suite ID is a protected header parameter (label -65537, from the private-use range). Each stack entry is a COSE_Signature. This is the natural container for mdoc/ISO 18013-5 profiles that already use COSE.
- SD-JWT-VC. The disclosure array is unchanged. The KB-JWT (key binding JWT) carries the suite ID in a protected header, and the issuer JWT header carries
pb_suitealongside the standard SD-JWT fields.
For each container, the invariant is the same: the suite ID is the authoritative identifier, algorithm strings are advisory at best, and every relying party is expected to fail closed on an unrecognized suite.
Migration from legacy JOSE
Most existing identity systems built on JOSE assume alg is the algorithm identifier. Migrating to a suite-ID protocol without breaking existing verifiers takes four steps, in this order:
- Emit both. New assertions carry
"alg"(for legacy verifiers) and"pb_suite"(for suite-aware verifiers). The two must agree, and any disagreement is a hard reject. - Deprecate
alg-only paths. Suite-aware verifiers stop honoring"alg"on assertions that also carry"pb_suite". This is invisible to well-formed assertions and closes off substitution attacks. - Require suite ID. After a published transition window (a full 12 months is typical), the issuer stops producing assertions without
"pb_suite". Legacy verifiers that never migrate keep working against archived assertions. - Retire
algon issuance. Only"pb_suite"is emitted. Any relying party still consuming production assertions is, by this point, suite-aware.
The migration is a runtime operation. It does not require re-issuing historical credentials, because L4 was algorithm-blind all along—the same JCS-canonicalized payload the ES256 signature covered in 2026 is the same payload the ML-DSA-65 signature will cover in 2036.
Cost and benefit
The wire-format discipline described above is not free. It costs roughly 40 extra bytes per assertion for the suite ID, offered-suites list, and registry version—negligible against the ~3.3 KB hybrid signature payload already required for post-quantum resilience. It costs a per-issuer registry endpoint, which is one line of DID-web configuration on top of the existing did-web trust root.
In return, it buys three properties that pure library-level agility cannot:
- Assertions issued today outlive the algorithm that signed them. Verifiers in 2036 do not need to be built on top of ML-DSA-65; they need to be built on top of the suite registry as of the assertion's
issued_at. - Downgrade attacks are structurally impossible, not just policy-forbidden. An attacker cannot get a verifier to accept a suite that neither party offered, because the transcript hash is inside the signed payload.
- Deprecation is a first-class wire event with a timeline, which removes any need for a coordinated global cutover. Old suites move through Proposed → Available → Default → Deprecated → Verify-only → Retired on published dates, and every relying party has 18 months of warning before verifications start failing.
For the PasskeyBridge platform—where every carrier signal, verifiable credential, and passkey attestation is signed exactly once and may be verified for a decade—this is the only architecture in which the assertions we sign this week are still meaningful the day ML-DSA is replaced by whatever comes next. That makes it a requirement rather than an optimization.
Read the hybrid signature implementation → See the ML-DSA-87 vs SLH-DSA-256 analysis for long-lived credentials → Read the KEM replacement architecture → Get started with PasskeyBridge →