Security & Cryptography

The cryptographic architecture in production: hybrid ECDSA and ML-DSA signatures, BLAST session tunnels, the quantum-seeded DRBG, per-tenant keys and rotation, encryption at rest, and what is still roadmap.

Last reviewed September 21, 2026Fresh

Overview

The platform never stores a personally identifiable value in the clear at rest. Low-entropy identifiers are keyed HMAC-SHA-256 digests computed under a server-held pepper, which is what makes them non-enumerable: an unkeyed SHA-256 of a phone number or an email address can be reversed by building a table once. Operational PII that has to be read back is AES-256-GCM ciphertext, decrypted just in time inside an audited function scope.

Five layers, each with its own module:

LayerPrimitivesPurpose
Credential and attestation signaturesES256 (P-256) or ES384 (P-384), plus ML-DSA-65 or ML-DSA-87 (NIST FIPS 204)A classical signature any stock verifier can check, with a post-quantum proof beside it
Internal proof objectsHMAC-SHA-256 plus ML-DSAIntegrity seals on rows the platform verifies itself
Session tunnels (BLAST)X25519 ECDH, HKDF-SHA-256, AES-256-GCMForward-secret channel for data in transit
RandomnessAES-CTR-DRBG seeded from quantum hardware, with SP 800-90B health tests on every drawNonces, salts and session identifiers
Data at restAES-256-GCM with a 12-byte IVPII, tenant private keys, session material

Every cryptographic operation leaves a record. Reseeds land in shield_entropy_pool, key rotations and administrative actions in shield_audit_log, decryptions in pii_decryption_log, and each signed artifact records the identifier of the DRBG seed its nonce came from, so a provenance receipt can be issued for it later.

Hybrid post-quantum signatures

Two constructions ship, and which one is used depends on who has to verify the result.

ConstructionClassical layerPost-quantum layerVerified by
Standards-basedES256 or ES384 compact JWS under the tenant's own keyDetached ML-DSA signature over the same signing inputAnyone, from the tenant's DID document
Internal hybrid (hybridSign)HMAC-SHA-256 under a key derived from the platform master keyML-DSA signature bundled with itThe platform, which holds the symmetric key

The standards-based envelope is the default for credentials and for everything that leaves the platform. The internal hybrid covers rows the platform re-verifies itself, and the legacy PQC-HYBRID credential envelope a tenant can still opt into by setting proof_format_preference to hmac.

Key derivation. ML-DSA key pairs are derived deterministically from the platform master key and never stored:

HMAC-SHA-256(SHIELD_ENCRYPTION_KEY, "pqc-sign:ml-dsa:{domain}")     -> 32-byte seed -> ML-DSA key expansion
HMAC-SHA-256(SHIELD_ENCRYPTION_KEY, "pqc-sign:classical:{domain}")  -> 32-byte HMAC-SHA-256 key

The same label serves both parameter sets: per FIPS 204 every ML-DSA parameter set takes a 32-byte seed, which the library expands. domain is usually the tenant id, so one tenant's keys are independent of another's, and derived pairs are cached per isolate because the expansion is the expensive part.

Sizes.

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

The classical signature beside it is 64 bytes for ES256 or 96 bytes for ES384 (r and s concatenated), or 32 bytes for the HMAC-SHA-256 layer.

Nonce fortification. Before signing, a 32-byte quantum-seeded nonce is generated and the signed input becomes {payload}:qnonce:{nonce}, so two signatures over the same payload differ. The nonce travels with the signature, and the result also carries entropy_seed_hash, the SHA-384 of the DRBG seed the nonce was stretched from.

Verification. hybridVerify checks both layers and returns classical_valid, pqc_valid and a composite valid that requires both. For the standards-based envelope the two layers are checked separately by the relying party: the JWS against the EC key in the tenant DID document, and the detached ML-DSA proof against the #mldsa-1 verification method in the same document, neither of which needs a PasskeyBridge secret.

Where each is used.

ArtifactConstructionCondition
Verifiable credentials and OID4VCIStandards-basedAlways, unless the tenant opted into the legacy envelope
Hosted passkey session attestationsStandards-basedAlways
Entropy provenance receiptsStandards-basedEnterprise
Security Event TokensDetached ML-DSA in four x-pb-pqc- headers beside the JWS; a SET whose proof cannot be made is held for the next tickAlways
Outbound signal webhooksDetached ML-DSA in four x-pb-pqc- headers beside the HMAC signatureRetried deliveries only; the inline first attempt carries the HMAC signature alone
Chain checkpointsInternal hybrid, pinned to ML-DSA-65Every checkpoint
Cross-reference bindingsInternal hybrid, ML-DSA-65Only when the tenant's pqc_enabled flag is on
Cached proofsInternal hybrid, at the tenant's levelOnly when pqc_enabled is on
Credential verification resultsInternal hybrid, ML-DSA-65Only when the provider has pqc_enabled
A2A attestationsInternal hybrid, ML-DSA-65Always

ML-DSA-87. Level 5 is an Enterprise add-on at $149 per month. A tenant's pqc_level selects the parameter set, and it also selects the classical curve for newly minted keys: ml-dsa-87 provisions ES384 (P-384), which pairs with ML-DSA-87 as CNSA 2.0 specifies, and every other tenant uses ES256 (P-256). Credentials, OID4VCI, cached proofs, passkey-RP attestations, receipts and boundary proofs follow the level; cross-reference bindings and chain checkpoints stay on ML-DSA-65. Existing ML-DSA-65 signatures stay valid, and an existing ES256 key keeps signing ES256 until it is rotated, so no credential carries a header that disagrees with its key. See Post-Quantum Cryptography Migration Guide.

BLAST session tunnels

BLAST (Biometric-Linked Asymmetric Session Tunnel) derives a short-lived AES-256-GCM key from an ephemeral X25519 exchange with a quantum-sourced salt. It runs over TLS and adds a key the transport layer does not hold.

Protocol.

  1. The client generates an X25519 key pair and sends its public key as 64 hex characters in blast_init.
  2. The server generates its own X25519 key pair in the same call, derives the shared secret, and returns its public key with a session id and a key fingerprint.
  3. The shared secret goes through HKDF-SHA-256 with a 32-byte quantum-seeded salt and the info string blast-tunnel-v1:{session_id} to produce the session key.
  4. Payloads are encrypted with AES-256-GCM under that key, with a fresh 12-byte IV per message.
PrimitiveAlgorithm
Key agreementX25519 ECDH
Key derivationHKDF-SHA-256 with a quantum-seeded salt
Tunnel encryptionAES-256-GCM
Key fingerprintSHA-256 over the shared secret, truncated for audit linkage

Actions, on shield-blast, which needs an API key with the ingest scope and the Enterprise plan:

ActionBodyEffect
blast_initclient_public_key_hex (64 hex characters), optional ttl_msCreates the session and returns the server public key, session id and fingerprint
blast_encryptsession_id, plaintextReturns ciphertext and IV for an active session
blast_infosession_idExistence, expiry, teardown state, fingerprint and remaining lifetime
blast_teardownsession_idMarks the session torn down; returns true once, then false
blast_countnoneActive, unexpired sessions for the tenant

Lifetime. The default is five minutes, overridable per session with ttl_ms. Expired sessions are torn down by the cleanup_expired_records database function, which sets torn_down and stamps the time. Each session has its own key pair, so compromising one session's material tells an attacker nothing about any other.

Storage. The server private key, the shared secret and the tunnel key are AES-256-GCM encrypted before they are written to shield_blast_sessions, and the edge function rebuilds tunnel state from that row per request rather than holding keys between calls. Full protocol detail is in BLAST Protocol.

Quantum-seeded entropy

The platform seeds an AES-CTR deterministic random bit generator from quantum hardware and stretches it for nonces and salts.

Provider hierarchy, tried in order:

PriorityProviderSourceSecret required
1Outshift QRNGCisco quantum hardwareOUTSHIFT_QRNG_API_KEY
2QCi uQRNGPhotonic quantum hardwareQCI_QRNG_ACCESS_TOKEN and QCI_QRNG_PASSWORD
3ANU QRNGVacuum fluctuation measurementNone; free tier, prototyping only
4OS CSPRNGcrypto.getRandomValues()None; offline fallback

XOR mixing. Whichever provider supplies the draw, it is XOR-mixed with 32 bytes of local CSPRNG output before it becomes a seed, so a stale or cached provider response still yields a unique seed.

DRBG internals. An AES-256 key derived from the mixed seed; a 128-bit counter initialised to a random value rather than zero, so two isolates that somehow derived the same key still produce different streams; output generated by encrypting zero-filled blocks; a maximum of 1,024 bytes per call.

Re-seeding. Every 60 seconds or every 1,000 requests, whichever comes first. The first seed of an isolate is fetched synchronously; later refreshes run in the background off the still-valid current key, so signing never waits on an external fetch.

Health tests on every draw. Before a provider draw is mixed, the platform runs the two approved continuous health tests of NIST SP 800-90B section 4.4 on the raw bytes: the Repetition Count Test (4.4.1) and the Adaptive Proportion Test (4.4.2), at the standard's own false-positive probability of 2^-20. A draw that fails is rejected and the next provider is tried; the rejection is recorded on the reseed that follows. Two things are stated on every result rather than left implicit: the Adaptive Proportion window is the 32-byte draw itself (the standard specifies W = 512 for continuous operation, and the cutoff is computed for the actual window with the standard's criterion), and the min-entropy per sample the cutoffs assume (8 bits) is assumed rather than assessed. Nothing here validates or certifies a provider.

Audit trail. Every reseed is written to shield_entropy_pool with a SHA-384 hash of the mixed entropy (never the raw seed), the source provider, the seed size in bits, the health-test report, and the reseed reason (initial or interval_or_count). Since 2026-09-08 every pool row is also linked into the entropy_pool hash chain and covered by the five-minute signed checkpoint, and every artifact the platform signs records the seed its nonce came from. That is what an entropy provenance receipt resolves; see the next section.

Where the DRBG output is used:

  • BLAST tunnel salts and session identifiers
  • The quantum nonce mixed into every hybrid and detached post-quantum signature
  • The nonce each chain checkpoint consumes, so the checkpoint covers its own seed

Passkey challenges and agent tokens are generated by their own libraries from the OS CSPRNG and do not draw from this module.

Entropy provenance receipts

An entropy provenance receipt is a signed statement, for one artifact PasskeyBridge signed for you, of where the randomness in that signature came from and what stands behind it. It is included in the Enterprise plan.

What a receipt binds together:

  1. The artifact. A delivered webhook, a Security Event Token, an issued credential, a cached proof, a cross-reference binding, or a chain checkpoint. Each of these rows records entropy_seed_hash, the SHA-384 of the DRBG seed its quantum nonce was stretched from.
  2. The seed record. The shield_entropy_pool row with that hash: the provider that supplied the draw, the seed size, anything the provider reported about itself (provider_reported), and what the platform measured on the draw (platform_measured: the SP 800-90B section 4.4 health-test report, any rejected draws before it, and the reseed reason). The two are separate fields on purpose.
  3. The ledger position. The seed record's entry in the entropy_pool hash chain: its sequence number, content hash and link hash, with the link recipe stated.
  4. The covering checkpoint. The first five-minute checkpoint whose signed entropy_pool head reaches that position, with its statement hash, ML-DSA algorithm, NIST beacon pulse when anchored, and the public URL to fetch and verify it.

Coverage is stated on every receipt: checkpointed (all four present), chained_not_yet_checkpointed (the seed is chained; the next checkpoint will cover it, so ask again in five minutes), or seed_recorded_not_chained (the seed predates the pool chain, 2026-09-08).

Request:

curl "https://api.passkeybridge.io/v1/shield-entropy-receipt/{tenant_id}?artifact_type=webhook_delivery&artifact_id={delivery_id}" \
  -H "x-pb-api-key: pb_live_..."

artifact_type is one of webhook_delivery, security_event_token, credential, cached_proof, cross_reference or chain_checkpoint; artifact_id is the row's UUID (or the checkpoint's seq). The key needs the receipts scope. Read-only, 60 requests per minute per tenant. A 409 no_seed_reference means the artifact was written before seed recording began or was signed without the post-quantum layer.

Response: receipt (the claims), jwt (an ES256 or ES384 JWS under your tenant key, typ: pb-entropy-receipt+jwt), pqc (a detached ML-DSA proof over the same signing input), kid, issuer (your tenant DID) and verification (the recipe).

Verifying offline. Resolve your tenant DID document. Verify the JWS under the key named by its kid; verify the ML-DSA proof over the JWS signing input under #mldsa-1. Fetch receipt.pb.checkpoint.url, verify the checkpoint as that endpoint describes, and confirm statement.chains.entropy_pool.seq is at or beyond receipt.pb.ledger.seq. When the checkpoint carries a beacon pulse, confirm it at beacon.nist.gov. Nothing in that chain of checks needs a PasskeyBridge secret.

What a receipt does not claim. It does not certify or validate any entropy provider, and the health tests assume the min-entropy per sample rather than assessing it. Provider figures are the provider's; measured figures are the platform's, on that draw. A receipt says exactly what was drawn, tested, recorded, chained and signed, and no more.

Per-tenant key management

Every tenant signs with its own ECDSA key pair, in one of two provisioning modes.

BYOK. The tenant supplies base64 of either a private JWK (kty: "EC", crv: "P-256" or "P-384", including the d component) or a PKCS#8 DER blob. The importer validates the curve, derives the public key, encrypts the private JWK and stores only the ciphertext. Imported keys take a byok- key id prefix.

Platform-generated. With no supplied material, the platform generates a key pair with crypto.subtle.generateKey. These take a gen- prefix. The curve follows the tenant's post-quantum tier: P-384 (ES384) on ml-dsa-87, P-256 (ES256) otherwise.

ColumnContent
key_idbyok- or gen- plus eight hex characters; used as the DID verification method fragment
algorithmES256 or ES384, always matching the curve of the stored key
public_key_jwkThe real EC public JWK (kty, crv, x, y, kid, alg, key_ops), published in the tenant's DID document
private_key_encryptedAES-256-GCM ciphertext of the exported private JWK
is_activeOne active key per tenant, enforced by a partial unique index
rotated_at, grace_expires_atSet when the key is rotated out

Signing. The edge function reads the encrypted private key, decrypts it in memory with the platform master key, reads the curve from the stored JWK so the hash matches (SHA-256 for P-256, SHA-384 for P-384), signs, and discards the key material. The algorithm reported on the signature is derived from the key itself, so a header can never claim a curve the key does not have.

Public exposure. The published value is the full EC public key, not a fingerprint. It is served in the tenant DID document at did:web:passkeybridge.io:tenants:{slug}, alongside the ML-DSA public key as #mldsa-1, which is what lets a third party verify both layers of a credential.

Provisioning and rotation run through shield-admin-mutations with a tenant-admin session: provision_tenant_key (which answers 409 when an active key already exists) and rotate_tenant_key, both accepting an optional key_b64 for BYOK. Issuance also provisions lazily, so a tenant that never called either still has a key when its first credential is signed. See Tenant Key Management and Rotation.

Key rotation

Rotating a tenant key moves the current key into a grace window and provisions a new one in its place.

What a rotation does. The active key is marked inactive with rotated_at and grace_expires_at set 24 hours ahead. A new key is provisioned, BYOK if new material was supplied and platform-generated otherwise, on the curve that matches the tenant's post-quantum tier. During the window, verification accepts both the new key and the grace key, so credentials signed minutes before the rotation still verify. After it, only the new key is returned.

Policy records. shield_rotation_policies holds a schedule per tenant and resource type:

ParameterDefaultRange
rotation_interval_hours720 (30 days)1 to 8,760
grace_period_hours240 to the interval
auto_rotatefalseBoolean

What is implemented, and what the policy table does today. Rotation execution exists for tenant_key only; api_key and agent_certificate return "Rotation not yet implemented for resource type" and change nothing. More importantly for anyone planning around this: no shipped edge function and no scheduled job reads or writes shield_rotation_policies, and nothing calls the auto-rotation sweep, so auto_rotate has no effect at present. Key rotation happens when an administrator runs rotate_tenant_key, and each run writes an audit row with the old and new key ids, the BYOK flag and the grace expiry. Treat the policy fields as the documented intent for a rotation you perform, and put the schedule in your own calendar until the sweep is wired up.

Encryption at rest

Everything sensitive in the database is AES-256-GCM encrypted before it is written: tenant private keys, vault PII, BLAST session material, shadow proxy values, stored webhook secrets.

Format. base64(iv + ciphertext + tag), with a 12-byte IV generated fresh per operation and the 16-byte GCM tag appended to the ciphertext by the mode itself.

Master key. SHIELD_ENCRYPTION_KEY is a base64-encoded 32-byte secret held as an environment variable. It is never written to the database, never shipped to client code and never logged. describeEncryptionKeyShape() reports whether it is ok, missing, not base64 or not 32 bytes without revealing it, because a key that is present but undecodable fails every cryptographic path while a presence check reads green.

FunctionBehaviour
encrypt(plaintext)Always encrypts; throws when the key is missing or is not 32 bytes
decrypt(ciphertext)Always decrypts; throws on a wrong key or corrupted input, and never returns a value it could not decrypt
encryptIfAvailable(value)Encrypts, and throws with an explicit message when the key is absent

Encryption is mandatory in both directions. A missing or unusable key produces an error and never a silent plaintext write; a value that cannot be decrypted produces an error and never the stored value itself. A migration-era helper used to return an undecryptable legacy value unchanged, so a column written in the clear before encryption existed still worked. It was removed on 21 September 2026, after an audit of every column that reached it found no such value left in any of them.

The PII vault builds on this layer for operational personal data. Each decryption declares a purpose code and writes a row to pii_decryption_log with the function, field, purpose and timestamp. Purpose policies recorded in the dashboard state the hourly volume and session length a tenant expects per code; no function reads them, so they are review criteria and not a gate. See Purpose-Bound PII Vault.

DataStorageReadable again
Phone numbers, subject identifiers, SIM signal valuesKeyed HMAC-SHA-256 under the pepperNo
IP addressesKeyed HMAC-SHA-256 under the pepperNo
Emails and display names (operational)AES-256-GCMYes, just in time and audited
Tenant signing keysAES-256-GCMYes, in memory during a signing operation
BLAST session materialAES-256-GCMYes, per request
Entropy seedsSHA-384 of the mixed seed onlyNo
Atomic fingerprintsSHA-256 content digestNo

Defense in depth

Layers that sit beside the cryptography.

Database. Row-level security is enabled on tenant tables with real policies, and the audit tables have no client write path at all. Edge functions hold the service-role key and enforce authorization in the function; that key never reaches a client.

Authentication. _shared/api-key-auth.ts accepts two lanes. A dashboard session JWT resolves the user's tenant through shield_tenant_members and checks admin membership where a function requires it. An API key is hashed with SHA-256 and looked up in shield_api_keys, with is_active, scope and tenant binding all checked. The key digest is deliberately unkeyed: an API key is a high-entropy random secret rather than an enumerable identifier, and it is looked up by that digest.

Rate limiting. Two layers: an in-memory sliding window inside the isolate (60 requests per 60 seconds) as burst protection, then an atomic database counter through shield_check_rate_limit for limits that hold across isolates. Individual functions set their own ceilings, for example 60 requests per minute per tenant on entropy receipts.

Replay protection. Mutating A2A handshake actions require a nonce and a timestamp. Used nonces are stored in shield_a2a_nonces and rejected on a second presentation; expired ones are cleaned up by shield_cleanup_nonces.

Input validation. Ingest payloads are validated with Zod schemas that pin signal types, 64-character hex digests and UUID sources, and reject a value that has already been hashed. Outbound email and notification bodies escape HTML before interpolation.

Audit logging. shield_audit_log records actor identity and type, the keyed IP digest, the user agent, the action, resource type and id, a before and after diff, the result and a correlation id. The IP digest and user agent are nulled after 90 days by a daily job at 03:00 UTC. Audit and event rows are hash-chained and covered by a signed checkpoint every five minutes, so a later edit to a row is detectable.

Agent mTLS. Agent delegates can be bound to client certificates in shield_agent_certificates, pinned by SHA-256 fingerprint and checked for expiry, revocation and tenant binding during a handshake. No plan gate is applied to this path in code.

Cryptographic roadmap

InitiativeStatusDetail
ML-DSA-65 (Level 3)Shipped, all plansReal lattice signatures through mldsa-wasm, pinned to an exact version and guarded by a version check
ML-DSA-87 (Level 5)Shipped, Enterprise add-on at $149 per month32-byte seed like Level 3, signatures around 4,627 bytes, selected per tenant by pqc_level
ES384 classical pairingShipped with the Level 5 tierNew keys on P-384 so the pair matches CNSA 2.0; existing P-256 keys keep signing ES256 until rotated
Entropy provenance receiptsShipped, EnterpriseSigned receipt binding an artifact to its seed, the pool chain and a checkpoint
QRNG provider hierarchyShipped, all plansOutshift, then QCi, then ANU, then the OS CSPRNG, with SP 800-90B health tests on every draw
ML-KEM-768 key encapsulationRoadmapNo implementation exists today. Key agreement is X25519 ECDH in the BLAST tunnel.
API key rotation executionNot implementedThe policy shape exists; execution returns a not-implemented result
Agent certificate rotation executionNot implementedAs above

Cryptographic agility. Every signature column stores its algorithm identifier beside the signature, and key ids carry their own provenance prefix, so a new parameter set can be introduced without a schema migration and old signatures stay verifiable under the algorithm they were made with. That is how ML-DSA-87 shipped beside ML-DSA-65 with no rewrite of existing rows.

Related from the blog