Skip to content
downpipes docs

Cryptography: 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 primitives, the byte-level invariants that matter for a security argument, and the limits of the suite.

Every asymmetric operation pairs a classical primitive with a post-quantum one, so the scheme stays secure while either half of the pair holds. The suite gives no protection beyond that: a break of both halves of a pair defeats it. 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.

LayerPrimitiveWhat it protectsSource
Key encapsulation (KEM)X25519 plus ML-KEM-1024, combined over HKDF-SHA-384wraps the per-run master key to each recipientengine/src/crypto/kem.ts, combiner.ts
SignatureEd25519 plus ML-DSA-87, detached, both halves requiredsigns the archive root manifest and the account RUNLOGengine/src/crypto/sign.ts
Bulk encryptionAES-256-GCM in a 64 KiB-chunk streamevery sealed unit: data, secrets, the master capsuleengine/src/crypto/stream.ts
Hash, KDF and MACSHA-384, HKDF-SHA-384, HMAC-SHA-384content addressing, the key tree, fingerprints, the audit hash chainengine/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. The offline CLI uses filippo.io/mldsa, golang.org/x/crypto/hkdf and the Go standard library.

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. 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. Neither half can be stripped to downgrade the archive to a single signature. This is an and-condition, the opposite of confidentiality: 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

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 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 decapsulates an archive key depends on the posture. In strict break-glass-only there is no OPERATIONAL_PRIVATE, so the engine holds no key that opens an archive it wrote and can never unwrap one, which is the cryptographic statement of the no-custody model. In both postures, the control-plane auto-heal decapsulates CONFIG_RECIPIENT_PRIVATE to open the engine’s sealed configuration export, and that key opens no archive. 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 most finalises, 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: that 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. The engine never writes it to an archive object, never sends it anywhere, and never logs it. What is written to your destination is the capsule that wraps it. A break-glass restore, a break-glass prune or an attended verification goes the other way: your browser unwraps each run’s master and sends it to your own engine for that one action. The one place a master rests in plaintext is isolate memory. The one place it rests at all outside that is a run’s own checkpoint, wrapped, described next.

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. It 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).

A run too large for one invocation stores its master, wrapped

This is the one place a per-run master rests outside isolate memory, and it is easy to read the paragraphs above as ruling it out.

Every key in a run is derived from that run’s master, so a run that cannot finish inside a single Worker invocation has to derive the same keys again in a later one. The engine therefore wraps the 32-byte master and stores it in the run’s checkpoint in Durable Object storage, for the life of that run only. The wrapped master is deleted at completion (sealCheckpointForStorage and wrapMaster, engine/src/seal/checkpoint.ts). It is not stored in the clear and it is not a second copy of the capsule.

The wrap is what bounds the exposure. The wrapping key is derived by HKDF-SHA-384 from your engine’s own SIGNER_PRIVATE seed under a fixed engine-internal label; it is not a format constant, and no archive depends on it. The wrap itself is AES-256-GCM with a fresh 12-byte initialisation vector, and the run id is bound as the additional authenticated data, so a wrapped master lifted from one run cannot be replayed onto another run’s checkpoint without the decryption failing. The same wrap key protects the checkpoint’s resume cursor and any mid-record resume state, under distinct additional-data domains, because a cursor carries record names or, for a D1 WITHOUT ROWID table from engine 0.3.6, row key values. Record names are protected data in this format, and row key values are customer data.

So an attacker must hold both the Durable Object storage and the Worker’s SIGNER_PRIVATE secret to recover an in-flight run’s master. That is an attacker who is already inside your Cloudflare account holding the signing key. They can therefore forge archives outright, which is a strictly worse position than reading one in-flight run. Nothing here reaches the vendor, and nothing here touches an archive already sealed: a completed run’s bytes are recoverable only through the capsule and its recipient private keys, exactly as described above.

Two conditions narrow it further. A run that completes within its first inline slice never wraps its master at all, because it never leaves memory. And SLICED_RUNS_DISABLED removes the path entirely: the engine seals each run whole and buffered, at the cost of a hard per-invocation ceiling on run size (sealRunBuffered, engine/src/seal/runstate.ts). Sliced runs are the default, so on a default deployment any run that outgrows one invocation takes this trade-off.

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).

Both implementations share one set of known-answer vectors (engine/docs/security/cryptography-and-keys.md). The TypeScript engine’s format and crypto-protocol modules are a byte-for-byte port of the Go reference reader, not an independent implementation from the specification. The Go reader opens archives the engine wrote without running engine code, so recovery does not depend on the engine reading its own output. Agreement between the port and the reference does not settle how two independent readers of the specification would read 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 reference verifier for the format, and the in-account engine runs the same check, 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. It 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. Offline it is prevented only when the operator pins a minimum with --min-runlog-index. With that pin set, the reader refuses a RUNLOG whose maximum index is below the pinned high-water mark, unless you pass --allow-stale (downpipe/internal/format/freshness.go; the CLI flag is documented in the command reference). Without the pin, the reader cannot see the older RUNLOG as old. It only warns that --min-runlog-index was not set, so 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. This is worth knowing before you conclude the control is yours alone to operate. From engine 0.3.6, the in-account restore, the restore tests, the blind restore test, the keyless attestation and attended verification each pass a per-destination floor as the minimum. The floor is the highest RUNLOG index the engine confirmed it appended to that destination, so the console refuses a whole-document replay of an older, validly-signed RUNLOG, and nobody has to write a number down.

Two limits keep that from being the whole answer. The floor 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.

Known gaps

The first is rotation. The engine tracks a rotation cadence for the signer, the break-glass recipient and the operational recipient, and it alerts when a cadence lapses. It does not refuse a run when a cadence lapses. There is 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 is therefore an operational action you take, not a control the engine enforces (engine/docs/security/cryptography-and-keys.md).

The second, and the more important, is the constant-time question. The @noble/post-quantum library is not constant-time-guaranteed. downpipes 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 to open an archive in-account; on the seal path it only encapsulates to public keys and signs. In both postures the control-plane auto-heal decapsulates the long-lived CONFIG_RECIPIENT_PRIVATE in-account to open the engine’s own sealed configuration export. That key opens only the configuration export, never an archive. 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 most successful finalises while VERIFY_AT_SEAL is on (it defaults on; the tier is also skipped over 5 GiB of plaintext or over 400 shards), 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 two-recipient 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. No constant-time post-quantum build is in place (engine/src/crypto/pq.ts), so downpipes makes no side-channel claim for those operations.

The third gap is memory hygiene. The per-run master and the keys derived from it live in isolate memory (and, for a run that outgrows one invocation, wrapped in that run’s checkpoint until it completes, as set out above). The garbage-collected runtime offers no explicit zeroisation primitive for those buffers, so destruction here means the value going out of scope on 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. 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 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. That encoding is the X25519 public key concatenated with the ML-KEM-1024 encapsulation key. A capsule wrap is addressed to a recipient by this fingerprint. 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 64 bytes: the 32-byte Ed25519 seed plus the 32-byte ML-DSA-87 seed, not the expanded several-kilobyte ML-DSA secret. Storing the seed keeps the encoded value small enough for the Cloudflare text-binding limit. 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 refuses a version it does not implement. 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 engine and console pin @noble/curves for X25519 and Ed25519, and @noble/post-quantum for ML-KEM-1024 and ML-DSA-87. In the engine, @noble/curves only derives the Ed25519 public key, and WebCrypto signs and verifies. The console verifies Ed25519 with @noble/curves.

WebCrypto provides AES-256-GCM, SHA-384, HKDF and HMAC. @noble/hashes provides the incremental SHA-384 and HMAC-SHA-384 that hash or seal a large value as a stream. The Go reader uses the standard library for X25519, Ed25519, ML-KEM-1024, AES-256-GCM and the SHA-384 family, with golang.org/x/crypto/hkdf for HKDF and filippo.io/mldsa for ML-DSA-87. The exact pins are the dependency versions in engine/package.json and downpipe/go.mod.

Last updated .