Skip to content
downpipes docs

The no-custody trust model: what the vendor and the engine can and cannot access

This page is the canonical account of the downpipes custody model. It is written for an evaluator who needs to know, precisely and provably, what is held where, who can read an archive in the worst case, and how a recovery survives the loss of both the vendor and Cloudflare.

The short version is that no-custody is structural rather than a promise. The running engine holds only public recipient keys plus its own signer private key, so it can seal and sign an archive but can never unwrap one it wrote. The single key that universally unwraps an archive, the break-glass private, is generated on your side and is never transmitted anywhere. The vendor holds no customer data, no keys and no Cloudflare token, and there is no inbound path from the vendor into your account.

There is one honest caveat that this page states plainly rather than buries, because the optional two-recipient posture trades a little of this guarantee for an automated recovery check. That caveat is set out in full below.

What sits inside your account

The engine runs entirely inside your own Cloudflare account. The keys it reads are typed environment values declared on the Worker (engine/src/env.d.ts, the SIGNER_PRIVATE, BREAK_GLASS_PUBLIC, OPERATIONAL_PUBLIC and OPERATIONAL_PRIVATE bindings). These are plain in-account Worker secrets. They are not a Cloudflare Secrets Store binding. The identity-provider connector defines a secrets-store secret mode for a confidential client, but it is refused at config time, before the connection is ever saved: validateSecretRef rejects it with “secrets-store secret resolution is not yet supported” and createIdpConnection returns that rejection before the record reaches storage (engine/src/admin/idpconn-validators.ts, engine/src/admin/oidc-store.ts). So no live path reads a real Secrets Store binding today; a confidential client’s secret is held only through the do-plaintext floor, a Durable Object value that is never read back to the console.

The engine reads exactly two kinds of long-lived asymmetric material:

  • The signer private key (SIGNER_PRIVATE), an Ed25519 seed plus an ML-DSA-87 seed totalling 64 bytes. This is the one private key the engine necessarily holds, because it must sign every run. loadSigner imports the Ed25519 half as a non-extractable Web Crypto signing key and derives the ML-DSA-87 secret from its seed (engine/src/keys-env.ts).
  • The public recipient keys (BREAK_GLASS_PUBLIC, and the optional OPERATIONAL_PUBLIC), each a 1600-byte X25519 plus ML-KEM-1024 public encoding. The engine seals a run to these public halves and, holding no matching private key, can never open the result (loadRecipients and loadRecipientPublic, engine/src/keys-env.ts).

The per-run master key, the 32-byte root that protects everything in a run, exists only in isolate memory for the duration of one run. It is wrapped to each recipient and that wrapped capsule, never the master itself, is what gets written.

Why the engine cannot read its own archives

Sealing wraps the master to each recipient by a hybrid encapsulation, then seals the master under a key derived from the resulting shared secret (sealToRecipients, engine/src/crypto/capsule.ts). The encapsulation step takes a public key and produces a ciphertext plus a shared secret; reversing it to recover the secret needs the recipient private key. The engine has only public recipient keys, so it can encapsulate but cannot decapsulate.

On the seal path the engine encapsulates and signs. Whether it also decapsulates depends on the posture, and the difference is the operational private key set out below. In strict break-glass-only there is no operational private key, so the engine never decapsulates a long-lived key in-account: encapsulate plus sign is the whole trusted production surface, which is the cryptographic statement of no-custody. In the two-recipient posture the engine does decapsulate the operational private key in-account on the production path, so signature verification and decapsulation both run there (verify-at-seal verifies on every read and its sampled tier decapsulates); the bound is that it is the engine’s own operational key on its own account, set out in full below (engine/src/crypto/pq.ts).

The break-glass key is the only universal unwrap

The break-glass private identity is a 96-byte identity: an X25519 scalar (32 bytes) plus an ML-KEM-1024 seed (64 bytes). It is generated in the operator’s browser during the key ceremony, or on a deploy machine using the MIT-licensed reader, from the platform random source (runKeyCeremony and makeRecipient, console/src/keygen.ts; the Go reference generates the same identity from crypto/rand).

It is downloaded as identity.key and kept by you, offline. It is never sent to the engine and never to the vendor. There is deliberately no field, no command and no install path that uploads it: the console ceremony states this in code, and confirms the browser generated it and the vendor received nothing (console/src/screens/onboarding-ceremony.ts). The engine holds only the matching public half.

The break-glass key is yours to protect

Because the break-glass private key is the only universal unwrap and it lives only with you, its loss is irrecoverable by design. The recovery sheet says so plainly and suggests splitting the identity across several offline holders so no single loss is fatal.

What the vendor can and cannot do

The vendor holds no customer data, no keys and no Cloudflare token, and there is no inbound path from the vendor into your account. The strongest action a hostile vendor could take is to ship a bad engine update or to withdraw assurance, and assurance is fail-open, so backups and recovery continue when it is withdrawn (engine/docs/THREAT-MODEL.md, adversary 5).

A bad update is itself constrained. The release-signer allowlist is pinned, so an unsigned update or one from the wrong signer is refused, and the default update path is a manual operator apply with no stored deploy token. An update that does run sees only the plaintext the engine reads during a run, which is inherent to any in-account writer, and it still cannot decapsulate what the break-glass key protects. The recovery path never runs vendor code at all: the open on-disk format plus the offline reader recover an archive regardless of the engine version.

The one honest caveat: the optional operational key

The two-recipient posture has two recipients, break-glass and operational. It is no longer the default: from 2026-07-28 a new estate defaults to break-glass only, and the operational key is what you opt into. The operational recipient exists so the engine can read a run back inside the account to prove it is recoverable, without a person present. Doing that needs a private key, so in the two-recipient posture the engine also holds a decryption-capable operational private key (OPERATIONAL_PRIVATE, loaded by loadIdentity, engine/src/keys-env.ts). That key is not used only by the drill: it powers the in-account read-back used by verify-at-seal on the seal path (default-on), the hourly canary, scheduled restore tests, in-console restores, the unattended drill and the retention-prune pass, each decapsulating through openCapsule (engine/src/admin/drill.ts, engine/src/seal/verify-at-seal.ts, engine/src/canary/cycle.ts, engine/src/admin/restore.ts).

The two-recipient posture holds a key that can decrypt

In the two-recipient posture, a full compromise of your own Cloudflare account would expose the operational private key and the signer private key. With the destination bytes, an attacker could then read past archives through the operational path. This is a compromise of your own account, never the vendor; the vendor never holds either key. Strict break-glass-only posture removes the operational key entirely, so a full account compromise yields no key that opens an archive. The cost is the unattended proof over past runs, not in-account read-back itself: verify-at-seal and the hourly canary both still decrypt, from a per-run master rather than a standing key. See recovery postures.

The break-glass key is unaffected by an account compromise in either posture, because it is offline and was never in the account. When the operational key is absent, the drill does not pretend; it reports that recovery must be exercised offline with the break-glass key and the MIT-licensed reader (engine/src/admin/drill.ts).

Who holds what

The matrix below is the precise custody position over the four key types. The engine column splits by posture, because the operational private key is the one cell that differs between them.

Key type Vendor Engine (break-glass-only) Engine (two-recipient) Customer, offline
Break-glass private Not held Not held Not held Held
Operational private Not held Not held Held Not held (optional copy is yours)
Signer private Not held Held Held Not held (verifier public is published)
Recipient public keys Not held Public only Public only Public only

Break-glass-only is what a new estate ships with; two-recipient is opted into. The single difference between the two postures is the operational private cell. Everything else is identical: the vendor holds nothing, the engine never holds the break-glass private, and the recipient keys the engine reads are public-only.

What a destination-bucket holder sees

An attacker who has only your offsite destination bytes, and none of your keys, sees ciphertext plus the cleartext root metadata. The root metadata is run identifiers, timing, sizes, recipient fingerprints and the algorithm suite (engine/docs/THREAT-MODEL.md, adversary 1).

An attacker with only the destination bytes never sees plaintext values, names or secrets. Every content key and file key derives from the per-run master, and the master lives only inside the capsule wraps, openable solely by a recipient private key they do not have. The capsule also binds the signed run key commitment as its authenticated additional data, so a swapped or replayed capsule fails authentication even on the byte-extraction path that skips signature checks (engine/src/crypto/capsule.ts).

The reviewer argument in one line

Confidentiality depends on a key the vendor never has; integrity depends on a signature checked against an operator-pinned signer; recovery depends on the open format plus the offline break-glass key, with neither Cloudflare nor the vendor required. Every assurance feature is built so that losing it degrades assurance only, never your backups and never your recovery.

A note on wording that matters here. The archive integrity property is tamper-evident: a rewritten archive is detected because the root signature, the recipient-set hash, the shard hashes, the Merkle root and the per-record hashes are all recomputed and checked. A bucket-write attacker can delete objects, which the completeness check surfaces loudly, but cannot forge a run that a verifying reader accepts.

Deeper detail: the keys, formats and the drill

The signer private and why the seed form. SIGNER_PRIVATE stores the 64-byte seed pair (a 32-byte Ed25519 seed plus a 32-byte ML-DSA-87 seed) rather than the expanded secret, so the encoded value stays around 86 base64 characters and fits the Cloudflare text-binding size limit; the engine derives the expanded key deterministically at load time (engine/src/keys-env.ts). The Ed25519 half is imported as a non-extractable Web Crypto signing key.

The recipient and identity byte formats. A recipient public key is X25519 public (32) plus ML-KEM-1024 encapsulation key (1568), 1600 bytes. A recipient private identity is X25519 scalar (32) plus ML-KEM-1024 seed (64), 96 bytes. The same formats are read by the MIT-licensed Go reader, so an identity.key saved from the console works directly with the offline binary (console/src/keygen.ts, engine/src/crypto/capsule.ts).

How a capsule is opened (for the reader, not the engine). openCapsule selects the wrap addressed to the held identity by recipient fingerprint, decapsulates to recover the shared secret, derives the wrap key and opens the sealed master, with the signed key commitment as additional data (engine/src/crypto/capsule.ts). This is the path the offline reader and the in-account drill take. The engine itself never calls it on the production path.

The in-account drill, precisely. The drill opens a run with the operational private key, recomputes the full keyed structural chain over every record, then blind-restores a stride-sample of records spread across the archive, measuring the verified plaintext bytes and wall-clock so it can project a recovery-time estimate (engine/src/admin/drill.ts). When OPERATIONAL_PRIVATE is absent it returns a clear break-glass-only result rather than a false pass.

No enforced rotation cadence. The format supports recipient rotation and signer rotation, but there is no expiry, no scheduled re-key and no automatic re-wrap of existing archives anywhere in the code. A rotation schedule is an operational commitment, not a control the engine enforces (engine/docs/security/cryptography-and-keys.md, section 3.4).

Where this fits

This page owns the custody story for the whole site. The posture choice that the operational-key caveat turns on lives in recovery postures, which sets out the two-recipient posture against strict break-glass-only and the trade-off each makes. The way these claims are phrased, and the claims the docs deliberately never make, are collected in precise claims and honesty.

Next steps

For how a run is taken, sealed and proven recoverable end to end, see how downpipes works. For the full adversary-by-adversary account that this page summarises, the threat model lives alongside the engine source and is the basis for the who-can-do-what claims here.

Last updated .