Cryptography, stated precisely: post-quantum hybrid encryption and signing
downpipes encrypts with a post-quantum hybrid suite: X25519 plus ML-KEM-1024 for key encapsulation, Ed25519 plus ML-DSA-87 for signing, and AES-256-GCM for the bulk data stream. This page documents that suite, the way one backup run is wrapped into a per-run capsule, and the gaps the suite does not close. It is written for an evaluator who needs the exact primitives, the exact byte-level invariants that matter for a security argument, and a precise account of what is and is not claimed.
Every asymmetric operation pairs a classical primitive with a post-quantum one, so the scheme stays secure if either half of the pair holds. That phrasing is exact and load-bearing. The suite does not claim to be unbreakable or immune to a future quantum computer; it is a hybrid that hedges a future break of one primitive with a second, independent one.
The byte-exact on-disk field layouts and the conformance vectors are reference-section material. This page is the conceptual suite and the design intent behind it.
The suite at a glance
The table below is the whole suite. Each asymmetric row is a matched pair, not a choice of one primitive; the symmetric and hash rows are single primitives used consistently.
| Layer | Primitive | What it protects | Source |
|---|---|---|---|
| Key encapsulation (KEM) | X25519 plus ML-KEM-1024, combined over HKDF-SHA-384 | wraps the per-run master key to each recipient | engine/src/crypto/kem.ts, combiner.ts |
| Signature | Ed25519 plus ML-DSA-87, detached, both halves required | signs the archive root manifest and the account RUNLOG | engine/src/crypto/sign.ts |
| Bulk encryption | AES-256-GCM in a 64 KiB-chunk stream | every sealed unit: data, secrets, the master capsule | engine/src/crypto/stream.ts |
| Hash, KDF and MAC | SHA-384, HKDF-SHA-384, HMAC-SHA-384 | content addressing, the key tree, fingerprints, the audit hash chain | engine/docs/security/cryptography-and-keys.md |
ML-KEM-1024 is the key-encapsulation mechanism standardised in FIPS 203, and ML-DSA-87 is the signature scheme standardised in FIPS 204. Naming the standards says where the algorithms come from. It does not say downpipes carries a FIPS validation; it does not. The post-quantum primitives are provided by the @noble/post-quantum library in the TypeScript engine and console, and by filippo.io/mldsa plus the Go standard library in the offline CLI.
Why hybrid, on both axes
The reason for pairing primitives is a harvest-now-decrypt-later hedge. An adversary can copy your sealed destination bytes today and wait for a cryptographic advance that breaks one primitive. If the only protection were a single classical KEM, that future break would expose every archive copied before it. Pairing a classical and a post-quantum primitive means a single advance against either one, on its own, exposes nothing.
The hedge runs on both the confidentiality axis and the integrity axis, and it is deliberately asymmetric in how the two halves combine.
For confidentiality, the KEM combiner binds the ML-KEM-1024 shared secret and the X25519 shared secret into one 32-byte key, and the derived key is secure if either half holds. A break of X25519 alone, or of ML-KEM-1024 alone, does not recover the master key. This is an or-condition: one good half is enough.
For integrity, verification requires that both the Ed25519 and the ML-DSA-87 signatures pass. A forgery must therefore defeat a classical scheme and a post-quantum scheme at once, and neither half can be stripped to downgrade the archive to a single signature. This is an and-condition, the opposite stance from confidentiality, and it is the right one: for secrecy you want any surviving primitive to protect you, and for authenticity you want every primitive to have to be broken before a forgery is accepted.
The hybrid KEM, precisely
The KEM wraps the per-run master key to a recipient’s public key. The recipient public key is an X25519 public key paired with an ML-KEM-1024 encapsulation key. Encapsulation produces a hybrid ciphertext that is the ML-KEM ciphertext followed by the X25519 ephemeral public share, 1600 bytes in total (encapsulateHybrid, engine/src/crypto/kem.ts).
The two component shared secrets are combined, not concatenated and hoped over. The combiner runs HKDF-SHA-384 with the ML-KEM secret and the X25519 secret as the input keying material, an empty salt, and an info string that binds the suite label, the X25519 ciphertext and the recipient X25519 public key (hybridKEMCombine, engine/src/crypto/combiner.ts). Binding the ciphertext and the recipient key into the derivation is what stops a shared secret from being lifted out of one context and replayed in another. The binding set mirrors the X-Wing construction, generalised to ML-KEM-1024 over HKDF-SHA-384, where X-Wing itself is fixed to ML-KEM-768.
One contributory-behaviour guard matters for the security argument. The X25519 exchange rejects an all-zero shared secret rather than proceeding, because some implementations return all-zero on a low-order point instead of erroring. Rejecting it keeps a malicious public key from forcing a predictable secret (engine/src/crypto/x25519.ts).
On the seal path the engine encapsulates to public recipient keys and signs. Whether it ever decapsulates depends on the posture. In strict break-glass-only there is no OPERATIONAL_PRIVATE, so the engine holds only public recipient keys and can never unwrap an archive it wrote, which is the cryptographic statement of the no-custody model. In the two-recipient posture the engine does hold a decryption-capable operational private key and decapsulates it in-account on the production path (verify-at-seal’s sampled read-back on every finalise, the hourly canary, scheduled restore tests, in-console restores and the retention-prune pass), so the bound there is narrower: it is the engine’s own operational key reading the engine’s own account, never a key the vendor holds (engine/src/crypto/pq.ts, engine/src/crypto/kem.ts). The full posture detail is in recovery postures.
The hybrid signature, and why neither half can be stripped
A detached signature is the Ed25519 signature followed by the ML-DSA-87 signature. The Ed25519 half is a fixed 64 bytes, which is exactly what lets a verifier split the two halves at a known offset and check each (engine/src/crypto/sign.ts).
Verification is strictly boolean and conservative. It returns false unless the signature is exactly 4691 bytes, the 64-byte Ed25519 prefix plus the 4627-byte ML-DSA-87 suffix, the Ed25519 half verifies, and the ML-DSA-87 half verifies. A malformed verifier or a malformed signature returns false rather than throwing, so a bad signer key reads as did-not-verify, never as a crash (hybridVerify, engine/src/crypto/sign.ts). There is no path that accepts one half alone, and there is no negotiated downgrade to a single scheme, so an attacker cannot present an archive signed only classically and have it pass.
Verification pins the operator's signer, not the manifest's hint
A reader verifies against the signer public key the operator supplied out of band, and it ignores any self-asserted signer hint carried in the manifest. A manifest cannot nominate the key that will check it. This is why an attacker who rewrites an archive and re-signs it with their own key still fails verification against the pinned operator key.
How one run becomes a capsule
A backup run is protected by a single fresh 256-bit master key, generated per run from the platform random source and held only in isolate memory for the duration of that run. The master is never written to any object, durable-object state or log. What gets persisted is the capsule that wraps it.
The master is wrapped once per recipient. For each recipient the engine encapsulates to that recipient’s public key, derives a wrapping key from the resulting shared secret, and seals the 32-byte master under AES-256-GCM in the stream construction (sealToRecipients, engine/src/crypto/capsule.ts). The set of wraps is the master capsule.
The detail that makes the capsule resistant to swapping is the additional authenticated data. The signed run-key commitment is bound as the AES-GCM additional data of each wrap. A capsule lifted from another run, or an old capsule replayed against a newer archive, carries a commitment that does not match, so the authenticated decryption fails. That holds even on an extraction path that skips signature verification, and even though AES-GCM is not by itself key-committing (engine/src/crypto/capsule.ts). The exact recipient set is also folded into the signed root through a recipient-set hash, so the set a run was sealed to cannot be quietly altered.
Below the master, every sealed unit gets its own fresh key. The per-unit key is not the file key directly; it is derived by HKDF-SHA-384 from the file key and the unit’s random payload nonce, so two units never share an AES-256 key (engine/docs/security/cryptography-and-keys.md). Recovery of the master is possible only for a recipient holding a matching private identity, which decapsulates, re-derives the wrapping key and opens the sealed master with the signed commitment as the additional data (openCapsule, engine/src/crypto/capsule.ts).
The bulk stream and its per-chunk binding
The stream construction frames a 16-byte payload nonce followed by AES-256-GCM chunks of 64 KiB of plaintext, the last of which may be shorter. Each chunk’s 12-byte nonce is an 11-byte big-endian counter, where the counter occupies the low 8 bytes and the top 3 are reserved zero, followed by a one-byte last-chunk flag (chunkNonce, engine/src/crypto/stream.ts).
That framing gives three properties without any extra machinery. A reordered or spliced stream fails authentication because each chunk’s nonce is tied to its position. A truncated stream that drops the final chunk is caught because the preceding chunk does not carry the last-chunk flag. And a hostile input can be bounded to the chunk count implied by a signed range, so a verifier never decrypts an unbounded body (openStream, engine/src/crypto/stream.ts).
Vendor-free recovery rests on two implementations agreeing byte for byte
The same suite is implemented twice: once in the TypeScript engine that writes archives, and once in the Go offline reader that recovers them with neither Cloudflare nor the vendor in the loop. The TypeScript code states the equivalence in line, for example that the combiner must reproduce the Go reference byte for byte. The on-disk format constants live in one file on each side and are documented as never edited without regenerating the vectors (engine/docs/security/cryptography-and-keys.md).
The two are kept honest by shared known-answer tests that run in both directions, so a drift in any algorithm or any encoding fails a test rather than passing silently (security/CRYPTO-INVENTORY.md). Say plainly what that proves: the TypeScript engine’s format and crypto-protocol modules are a byte-for-byte port of the Go reference reader (the modules say so in their own source comments), not a second implementation derived independently from the specification, so the cross-check is agreement between the port and the reference it was built from. What it does prove, and it is still worth having: the bytes the engine wrote are decrypted, verified and restored by the Go reference reader itself, including a tamper-rejection case, so recovery does not rest on the engine’s own code reading its own output back correctly. Be exact about when that runs, because it is a branch gate rather than a release gate. The writer-to-reader job lives in the engine’s continuous-integration workflow, which fires on a push to main and on a pull request against it, and it is one of the jobs the required success sentinel waits on, so a change cannot land on main without it (engine/.github/workflows/ci.yml). The release workflow is a different file, triggered by a v* tag, and it verifies the tag signature, builds deterministically, signs and publishes; it runs no reader step of its own (engine/.github/workflows/release.yml). So the honest statement is that every change to the engine’s main branch is proven against the reference reader, not that every release is re-proven at tag time. What it does not prove is that two people independently reading the specification would reach the same conclusion on an ambiguous clause.
Do not cross-check the engine and the offline reader by comparing their digests
The Go offline envelope is its own format. It is not an age file and it is not off-the-shelf interoperable with other tools, so do not treat it as one. In particular, the engine’s seal-time restore digest and the offline reader’s discard-sink digest are two different measurements taken on two different paths; they are not designed to be equal, so comparing them proves nothing. The right proof of recoverability is that the offline reader verifies the archive and restores it, which is the in-account drill and the offline downpipe verify and restore checks. See prove recoverability.
Anti-rollback is observable by default and prevented only when you pin
The account RUNLOG is an append-only, index-monotonic log that chains each downpipe’s runs. The offline reader is the authoritative verifier for the format, and the in-account engine runs the same check as a faithful port, so this is not a guarantee that only reaches you if you have the CLI in your hands. On every verified read either reader inspects the RUNLOG for a duplicated index, a break in a downpipe’s chain, or a dangling or forked predecessor, and flags any anomaly as a rollback warning while returning a stale outcome (detectChainAnomaly, downpipe/internal/format/freshness.go).
So a rollback is observable by default: the reader tells you when the run you are about to restore is not the latest for its downpipe, or when the chain is internally inconsistent. A wholesale rollback to an older but internally consistent RUNLOG is a different threat, and offline it is prevented only when the operator pins a minimum with --min-runlog-index. With that pin set, a RUNLOG whose maximum index is below the pinned high-water mark is rejected outright (engine/docs/security/cryptography-and-keys.md; the CLI flag is documented in the command reference). Without the pin, the older RUNLOG is reported but not refused; the pin is the affirmative control on that channel.
In-account, the same pin is applied for you on the paths that make a recoverability claim, and this is worth knowing before you conclude the control is yours alone to operate. The blind restore test and the keyless attestation read the scheduler’s live run-index counter and pass it as the minimum, so a whole-document replay of an older, validly-signed RUNLOG is refused in the console without anyone having written a number down. Two limits keep that from being the whole answer. The counter lives inside the same Cloudflare account, so it stops a destination-side adversary and not one who has taken the account, which is the adversary your offline pin on the recovery sheet is for. And it is applied on those paths rather than everywhere: verify-at-seal’s Tier-0 attestation and the scheduled reconciliation run without it, so they observe a replay rather than refusing it.
The honest gaps
This page names the gaps the suite leaves open, because an evaluator should not have to find them by reading the code.
The first is rotation. There is no enforced rotation cadence anywhere in the code: no expiry on the signer or any recipient, no scheduled re-key, and no automatic re-wrap of existing archives to a new recipient set. The format supports rotation, since the recipient set is a fingerprint-addressed list and the signer is operator-pinned, so new runs immediately use a new set while old archives stay openable by the identity that sealed them. A rotation schedule is therefore an operational commitment you make, not a control the engine enforces (engine/docs/security/cryptography-and-keys.md).
The second is the constant-time question, and it is the more important one to state plainly. The @noble/post-quantum library is not constant-time-guaranteed. This page makes no side-channel-resistance claim about the post-quantum operations. What bounds the exposure depends on the posture. In strict break-glass-only there is no OPERATIONAL_PRIVATE, so the engine never decapsulates a long-lived key in-account at all; it only encapsulates to public keys and signs. In the two-recipient posture the engine does decapsulate the long-lived operational private key in-account on the production path: verify-at-seal’s sampled-decrypt tier runs on every successful finalise while VERIFY_AT_SEAL is on (it defaults on), and the same in-account read-back drives the hourly canary, scheduled restore tests, in-console restores and the retention-prune pass. So the bound in the default posture is not that decapsulation stays off the hot path; it is that the decapsulation is the engine’s own operational key reading the engine’s own account, never a key the vendor holds. The posture detail is in recovery postures. Should a constant-time post-quantum implementation become a requirement, the documented escalation is a Rust or WASM build of the affected primitives (engine/src/crypto/pq.ts). That is recorded as an honest limitation and a plan, not as a mitigation already in place.
A third, smaller point of honesty is memory hygiene. The per-run master and the keys derived from it are ephemeral isolate-memory values that are never persisted, but the garbage-collected runtime offers no explicit zeroisation primitive for those buffers, so destruction here means non-persistence and isolate teardown rather than a guaranteed wipe (engine/docs/security/cryptography-and-keys.md).
Where this fits
This page is the suite. The custody story, which is what the no-custody argument actually turns on, lives in the no-custody trust model, and the way the keys are generated and split across holders is in the key ceremony and recovery kit. For how these primitives are wired into a single run from crawl to seal, see the anatomy of a backup run. The way these claims are phrased, and the claims the docs deliberately never make, are collected in precise claims and honesty, and the format-versioning anchors that let a primitive change cleanly are in changelog and versioning.
Deeper detail: sizes, fingerprints and the agility story
Hybrid ciphertext and signature sizes. The hybrid KEM ciphertext is the 1568-byte ML-KEM-1024 ciphertext followed by the 32-byte X25519 ephemeral share, 1600 bytes. The detached signature is the 64-byte Ed25519 signature followed by the ML-DSA-87 signature, 4691 bytes. These fixed offsets are what let a verifier split each composite value deterministically (engine/src/crypto/kem.ts, engine/src/crypto/sign.ts).
Recipient fingerprint. A recipient is identified by a dpr1: prefix followed by the hex SHA-384 of the 1600-byte recipient encoding, which is the X25519 public key concatenated with the ML-KEM-1024 encapsulation key. A capsule wrap is addressed to a recipient by this fingerprint, and a held identity derives the same fingerprint from its private parts to select the matching wrap (recipientFingerprint, engine/src/crypto/capsule.ts).
Why the signer is stored as a seed. The signer private value stored in the account is the 32-byte Ed25519 seed plus the 32-byte ML-DSA-87 seed, 64 bytes, rather than the expanded several-kilobyte ML-DSA secret. Storing the seed keeps the encoded value small enough for the Cloudflare text-binding limit, and the engine derives the expanded secret deterministically at load time (engine/docs/security/cryptography-and-keys.md).
Cryptographic agility. Because the version label and every domain-separation string are part of the signed and derived material, a future suite is a clean break rather than an in-place edit: old archives stay bound to their own labels and verify under the suite that wrote them, and a reader dispatches on the version it reads. Each half of each hybrid pair is independently swappable, since the combiner and signature wire formats are explicit byte layouts, so one primitive can be replaced without disturbing the other (engine/docs/security/cryptography-and-keys.md).
Pinned libraries. The TypeScript side pins @noble/curves for X25519 and Ed25519 and @noble/post-quantum for ML-KEM-1024 and ML-DSA-87, with WebCrypto providing AES-256-GCM, SHA-384, HKDF and HMAC. The Go reader uses the standard library for X25519, Ed25519, AES-256-GCM and the SHA-384 family, with filippo.io/mldsa for ML-DSA-87. The exact pins are maintained in the cryptographic inventory (security/CRYPTO-INVENTORY.md).
Last updated .