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.

Last reviewed September 16, 2026Fresh

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:

ConstructionClassicalPost-quantumUsed for
Standards-basedES256 (P-256) or ES384 (P-384) compact JWS under the tenant's keyDetached ML-DSA over the same signing inputCredentials, OID4VCI, passkey session attestations, entropy receipts, webhook and Security Event Token boundary proofs
Internal hybrid (hybridSign)HMAC-SHA-256 derived from the platform master keyML-DSA bundled with itChain 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.

ComponentML-DSA-65ML-DSA-87
Seed32 bytes32 bytes
Private key4,032 bytes4,896 bytes
Public key1,952 bytes2,592 bytes
Signatureabout 3,309 bytesabout 4,627 bytes

Signing and verification

Signing with `hybridSign(payload, domain, level?)`:

  1. Draw a 32-byte quantum-seeded nonce from the DRBG.
  2. Build the signed input {payload}:qnonce:{nonce}.
  3. Sign it twice in parallel: HMAC-SHA-256 with the classical key, and ML-DSA with the derived lattice key.
  4. Compute a key fingerprint, the SHA-256 of fingerprint:{domain}, for audit linkage.
  5. 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.

SwitchWhere it livesWhat it controls
pqc_enabledshield_vc_providers, toggled in Settings under Post-quantum cryptographyWhether cross-reference bindings, cached proofs and credential verification results carry a hybrid signature at all
pqc_levelshield_tenants, set by the billing webhook when the add-on is activeWhich 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.

PathFollows pqc_level
Credential issuance and OID4VCIYes
Cached proofsYes
Hosted passkey RP attestationsYes
Entropy provenance receiptsYes
Webhook and Security Event Token boundary proofsYes
Cross-reference bindingsNo, they stay on ML-DSA-65
Chain checkpointsNo, pinned to ML-DSA-65
Credential verification result signaturesNo, 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.

ParameterML-DSA-65 (Level 3)ML-DSA-87 (Level 5)
Seed size32 bytes32 bytes
Private key4,032 bytes4,896 bytes
Public key1,952 bytes2,592 bytes
Signatureabout 3,309 bytesabout 4,627 bytes
NIST security level3 (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

StandardAlignment
NIST FIPS 204ML-DSA-65 (Level 3) and ML-DSA-87 (Level 5), finalised August 2024
CNSA 2.0The Level 5 tier pairs ECDSA P-384 with ML-DSA-87, the suite's signature combination
NIST FIPS 203ML-KEM is on the roadmap. Key agreement in production is X25519 ECDH in the BLAST tunnel.

Implementation.

  • mldsa-wasm is pinned to exactly 0.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.

ComponentReason
BLAST tunnelsX25519 plus AES-256-GCM, independent of the signature layer
WebAuthn and passkeysThe FIDO2 authenticator manages its own key pairs
Database schemaSignature columns already store an algorithm identifier per row
Audit logAlready records the algorithm used for each signature
Entropy systemThe DRBG is algorithm-agnostic

Related from the blog