Post-Quantum Cryptography Migration Guide
The hybrid post-quantum architecture in practice: the two signing constructions, deterministic ML-DSA seed derivation, what the pqc_enabled toggle and the pqc_level tier each control, and the ML-DSA-87 add-on.
Hybrid signature model
Every post-quantum signature the platform produces pairs a classical signature with an ML-DSA signature (NIST FIPS 204). If a quantum computer breaks the classical layer, the ML-DSA signature still holds; if ML-DSA is broken, the classical signature still holds.
Which classical layer depends on who verifies:
| Construction | Classical | Post-quantum | Used for |
|---|---|---|---|
| Standards-based | ES256 (P-256) or ES384 (P-384) compact JWS under the tenant's key | Detached ML-DSA over the same signing input | Credentials, OID4VCI, passkey session attestations, entropy receipts, webhook and Security Event Token boundary proofs |
Internal hybrid (hybridSign) | HMAC-SHA-256 derived from the platform master key | ML-DSA bundled with it | Chain checkpoints, cross-reference bindings, cached proofs, credential verification results, A2A attestations, and the legacy PQC-HYBRID credential envelope |
The distinction matters for integration. A standards-based credential is a normal JWS: any JOSE library validates it against the EC public key in the issuer's DID document, and a relying party that also wants the post-quantum layer verifies the detached ML-DSA proof against #mldsa-1 in the same document. An internal hybrid proof is verified by the platform, because its classical layer is a symmetric MAC the platform holds.
Outbound webhooks and Security Event Tokens carry the detached proof as four headers beside whatever signature the channel already had: x-pb-pqc-signature, x-pb-pqc-algorithm, x-pb-pqc-nonce and x-pb-pqc-kid. A receiver that does not know them ignores them.
Deterministic seed derivation
ML-DSA key pairs are derived from the platform master key on demand, so nothing stores a post-quantum private key.
SHIELD_ENCRYPTION_KEY (base64)
-> decode to 32 raw bytes
-> HMAC-SHA-256(masterBytes, "pqc-sign:ml-dsa:{domain}")
-> 32-byte seed
-> ML-DSA key expansion (mldsa-wasm, raw-seed import)
-> { privateKey, publicKey }One label for both levels. The derivation message is pqc-sign:ml-dsa:{domain} whether the tenant is on ML-DSA-65 or ML-DSA-87. Per FIPS 204 every parameter set accepts the same 32-byte seed and expands it internally, so the level selects the expansion rather than the seed.
The classical layer has its own label. pqc-sign:classical:{domain} derives the HMAC-SHA-256 key used by the internal hybrid construction, so the two layers share no key material. The standards-based classical layer does not use this path at all: it signs with the tenant's stored ECDSA key.
Domain separation. domain is usually the tenant id, and sometimes a fixed context string such as the chain-checkpoint domain or a2a:{tenant_id}. Two domains produce entirely independent key pairs from the same master key. Because derivation is deterministic, verification re-derives the pair instead of loading a stored key, and derived pairs are cached per isolate so the lattice expansion runs once per key rather than once per signature.
| Component | ML-DSA-65 | ML-DSA-87 |
|---|---|---|
| Seed | 32 bytes | 32 bytes |
| Private key | 4,032 bytes | 4,896 bytes |
| Public key | 1,952 bytes | 2,592 bytes |
| Signature | about 3,309 bytes | about 4,627 bytes |
Signing and verification
Signing with `hybridSign(payload, domain, level?)`:
- Draw a 32-byte quantum-seeded nonce from the DRBG.
- Build the signed input
{payload}:qnonce:{nonce}. - Sign it twice in parallel: HMAC-SHA-256 with the classical key, and ML-DSA with the derived lattice key.
- Compute a key fingerprint, the SHA-256 of
fingerprint:{domain}, for audit linkage. - Return the bundle.
{
"classical_signature": "base64",
"pqc_signature": "base64",
"pqc_algorithm": "ML-DSA-65",
"key_fingerprint": "hex",
"quantum_nonce": "hex",
"entropy_seed_hash": "sha384-hex",
"entropy_audit": null
}entropy_seed_hash identifies the DRBG seed the nonce came from and is stored beside the artifact, which is what makes an entropy provenance receipt possible later. entropy_audit is present only when this call surfaced a reseed.
Verifying with `hybridVerify`: reconstruct the signed input from the stored quantum_nonce, check both layers, and return { valid, classical_valid, pqc_valid, pqc_algorithm }, where valid requires both.
The detached variant, `signPqcDetached`, returns pqc_signature, pqc_algorithm, quantum_nonce and entropy_seed_hash with no classical component, because the classical signature in that envelope is the ES256 or ES384 JWS produced with the tenant's own key.
Two different switches, and they do different things.
| Switch | Where it lives | What it controls |
|---|---|---|
pqc_enabled | shield_vc_providers, toggled in Settings under Post-quantum cryptography | Whether cross-reference bindings, cached proofs and credential verification results carry a hybrid signature at all |
pqc_level | shield_tenants, set by the billing webhook when the add-on is active | Which ML-DSA parameter set, and which classical curve, new signing operations use |
Issuance does not consult pqc_enabled: an issued credential always carries its ML-DSA proof. To find the toggle, open Settings, expand Post-quantum cryptography (Enterprise only) and press Turn on or Turn off; the same panel displays the current algorithm and security level as read-only values.
ML-DSA-87 add-on
ML-DSA-87 (NIST Level 5) is an Enterprise add-on listed at $149 per month, giving the CNSA 2.0 pairing of ECDSA P-384 with ML-DSA-87.
How a tenant gets it. The add-on is a line item on the tenant's Stripe subscription. When the billing webhook processes a subscription carrying the add-on price, it sets pqc_level = "ml-dsa-87" on the tenant row; when that item is removed or the subscription is cancelled, the same reconciliation reverts it to ml-dsa-65. No dashboard button adds the line item: it is added to the tenant's subscription in Stripe, and the webhook applies it on the next subscription event. The add-on is listed on the pricing page. Settings shows the resulting level, and it is read-only there.
What changes when the level flips.
| Path | Follows pqc_level |
|---|---|
| Credential issuance and OID4VCI | Yes |
| Cached proofs | Yes |
| Hosted passkey RP attestations | Yes |
| Entropy provenance receipts | Yes |
| Webhook and Security Event Token boundary proofs | Yes |
| Cross-reference bindings | No, they stay on ML-DSA-65 |
| Chain checkpoints | No, pinned to ML-DSA-65 |
| Credential verification result signatures | No, they stay on ML-DSA-65 |
The classical curve moves with it, for new keys only. A tenant on ml-dsa-87 provisions ES384 (P-384) keys from then on. An existing ES256 key keeps signing ES256 until an explicit rotation, and the signature's algorithm is read from the key itself, so a credential never carries a header that disagrees with the key that signed it. Rotate the tenant key through the dashboard to move onto P-384.
| Parameter | ML-DSA-65 (Level 3) | ML-DSA-87 (Level 5) |
|---|---|---|
| Seed size | 32 bytes | 32 bytes |
| Private key | 4,032 bytes | 4,896 bytes |
| Public key | 1,952 bytes | 2,592 bytes |
| Signature | about 3,309 bytes | about 4,627 bytes |
| NIST security level | 3 (AES-192 equivalent) | 5 (AES-256 equivalent) |
On cancellation the level reverts to ml-dsa-65. Signatures already made with ML-DSA-87 stay valid and verifiable: each row records the algorithm it was signed with, and verification re-derives the matching key pair.
Standards and implementation
| Standard | Alignment |
|---|---|
| NIST FIPS 204 | ML-DSA-65 (Level 3) and ML-DSA-87 (Level 5), finalised August 2024 |
| CNSA 2.0 | The Level 5 tier pairs ECDSA P-384 with ML-DSA-87, the suite's signature combination |
| NIST FIPS 203 | ML-KEM is on the roadmap. Key agreement in production is X25519 ECDH in the BLAST tunnel. |
Implementation.
mldsa-wasmis pinned to exactly0.0.4, a WebAssembly build of the PQClean reference implementation. The pin is a signing-path dependency, so a dedicated check fails the build on any drift or range specifier. Version 0.0.3 shipped ML-DSA-65 only; 0.0.4 added Level 5, and ML-DSA-65 keys and signatures were verified byte-identical across the two before the bump.- Key pairs are derived from the master key on demand, so verification needs no key storage and a lost key store cannot orphan a signature.
- A quantum-seeded nonce is mixed into every signed payload, so the same payload signed twice produces different signatures.
- Both layers are checked independently, so a verification result can say which layer failed.
What a migration does not touch.
| Component | Reason |
|---|---|
| BLAST tunnels | X25519 plus AES-256-GCM, independent of the signature layer |
| WebAuthn and passkeys | The FIDO2 authenticator manages its own key pairs |
| Database schema | Signature columns already store an algorithm identifier per row |
| Audit log | Already records the algorithm used for each signature |
| Entropy system | The DRBG is algorithm-agnostic |