Skip to content
downpipes docs

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

This page sets out the downpipes custody model for an evaluator who needs to know 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. In the default posture, the running engine holds public recipient keys, its own signer private key and configuration keys that open no archive. With these keys 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.

The optional two-recipient posture trades part of this guarantee for an automated recovery check, as set out 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, and the configuration keys CONFIG_RECIPIENT_PUBLIC, CONFIG_RECIPIENT_PRIVATE and CONFIG_WRAP_KEY). 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). No live path reads a confidential client’s secret from a Secrets Store binding. The engine reads a Secrets Store binding only as a source to back up (engine/src/sources/secrets.ts). A confidential client’s secret is held only through the do-plaintext mode. That is a Durable Object value, sealed under CONFIG_WRAP_KEY when the engine holds one, and it is never read back to the console (engine/src/admin/oidc-store.ts).

In the default posture, the engine reads two kinds of long-lived asymmetric material for archives:

  • The signer private key (SIGNER_PRIVATE), an Ed25519 seed plus an ML-DSA-87 seed totalling 64 bytes. This is a private key the engine must hold, because it signs 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 in the default posture, can never open the result (loadRecipients and loadRecipientPublic, engine/src/keys-env.ts).

The engine holds two configuration keys as well. The configuration recovery private key (CONFIG_RECIPIENT_PRIVATE) is a 96-byte identity in the recipient format. It opens only the engine’s own sealed configuration export, so the engine can rebuild its configuration after a Durable Object wipe. It never opens an archive (engine/src/cron/control-plane-pass.ts).

CONFIG_WRAP_KEY is a 32-byte AES-256 key that encrypts the credentials the console stores, including those saved before the console installed it (rewrapAfterKeyInstall, engine/src/admin/config-rewrap.ts). Neither configuration key touches archive data.

The per-run master key, the 32-byte root that protects everything in a run, lives 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 to your destination. A run too large for a single Worker invocation additionally holds its master wrapped under a key derived from SIGNER_PRIVATE, in that run’s own Durable Object checkpoint, deleted at completion; recovering it needs both the Durable Object storage and the signing secret, and it never leaves your account. The full account is on cryptography.

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. In the default posture, the engine holds only the public halves of the archive recipient keys, so it can encapsulate to them 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 with a key that opens an archive. Its one in-account decapsulation opens its own sealed configuration export, with CONFIG_RECIPIENT_PRIVATE, when it rebuilds its configuration. For archives, 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 with 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 whenever that tier is not skipped for size, shard count or the VERIFY_AT_SEAL knob); 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 from the platform random source in the operator’s browser during the key ceremony (runKeyCeremony and makeRecipient, console/src/keygen.ts). The deploy script can generate it instead on the deploy machine (engine/scripts/generate-keys.ts). The MIT-licensed reader’s downpipe keygen makes the same identity format 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 key install body has no field for it (installCeremonyKeys, console/src/lib/setup-flow/install.ts), and setup’s Keys step says so before you make the keys (KEYS_TRUST_OFFLINE, console/src/screens/setup/b/q-keys.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 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 (adversary 5 in the threat model).

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. The update 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 exception: the optional operational key

The two-recipient posture has two recipients, break-glass and operational. Break-glass-only is the default, 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 returns ok:false and names the absent read-back key. Its reason points you to an attended verification, or to an operational key for future runs. Offline recovery with the break-glass key and the MIT-licensed reader stays available for older archives (engine/src/admin/drill.ts).

Who holds what

The matrix below sets out the custody position for six key types. The engine column splits by posture, because the operational private key is the one cell that differs between them.

Key typeVendorEngine (break-glass-only)Engine (two-recipient)Customer, offline
Break-glass privateNot heldNot heldNot heldHeld
Operational privateNot heldNot heldHeldNot held
Signer privateNot heldHeldHeldNot held (verifier public is published)
Recipient public keysNot heldPublic onlyPublic onlyPublic only
Configuration recovery privateNot heldHeldHeldNot held
Configuration wrap keyNot heldHeldHeldNot held

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 no other key the engine holds opens an archive.

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 (adversary 1 in the threat model).

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

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. 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 takes. In the two-recipient posture, the engine calls it with the operational key for verify-at-seal, the canary and the drill. In either posture, the engine calls it with the configuration recovery key to open its own configuration export (engine/src/admin/control-plane-seal.ts).

The in-account drill. 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 the engine has no expiry, no scheduled re-key and no automatic re-wrap of existing archives. 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 exception turns on lives in recovery postures. That page sets out the two-recipient posture against strict break-glass-only and the trade-off each makes.

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, see the threat model.

Last updated .