Skip to content
downpipes docs

Threat model: what downpipes protects against, and what it does not

This page is the reader-facing mirror of the engine threat model. It walks the five adversaries downpipes is designed against, says for each what the adversary can and cannot do, then states the residual risks in plain words rather than burying them.

It is written for an evaluator who wants to separate what the platform structurally prevents from what it can only detect or only degrade. The recurring property to watch for is this: an adversary can disrupt assurance, the reporting and the proof that a backup is good, but the same adversary cannot read your data and cannot stop your recovery. The two guarantees that hold across every adversary are confidentiality, which rests on keys the vendor never holds, and recoverability, which rests on the open format plus your offline break-glass key.

One scope note before the adversaries, because it is easy to misread. The full-account-compromise adversary below is a compromise of your own Cloudflare account, the one the engine runs inside. It is never the vendor. The vendor is its own, separate adversary, and it holds far less.

The five adversaries at a glance

The table is the summary. Each adversary then gets its own section with the precise reasoning from the code.

Adversary What it CAN do What it CANNOT do
1. Destination bucket bytes alone Read the cleartext root metadata (run identifiers, timing, sizes, recipient fingerprints, the algorithm suite) and the ciphertext Read any value, name or secret; every key derives from a master locked inside capsules it cannot open
2. Full compromise of your own Cloudflare account In the two-recipient posture, decrypt past archives through the operational key, and sign a new run with the signer key Decrypt through break-glass (its private key is offline); in the break-glass-only posture, open any archive at all
3. A bucket-write forger (rewrites objects, holds no keys) Delete objects, a denial of availability the completeness check surfaces loudly Forge a run that a verifying reader accepts
4. A malicious or compromised engine update If it runs, see the plaintext the engine reads during a run, a narrow but real window Run at all if unsigned or wrong-signer; decapsulate what break-glass protects; touch the recovery path, which never runs vendor code
5. The vendor itself Ship a bad update, or withdraw assurance (fail-open) Read data, reach into your account, hold a token, or prevent recovery

Adversary 1: the destination bucket alone

This is an attacker who has only your offsite destination bytes and none of your keys. A leaked bucket, a misconfigured share, a copy of the objects.

What it gets. The cleartext root metadata and the ciphertext. The root metadata is run identifiers, timing, sizes, recipient fingerprints and the algorithm suite (engine/docs/THREAT-MODEL.md, adversary 1).

What it cannot do. It cannot read any value, name or secret. The content-addressing key and every file key derive from the per-run master, and the master exists only inside the capsule wraps, openable solely by a recipient private key the attacker does not have. This is the offsite confidentiality guarantee, and it is the most common real-world exposure, so it is the one the design treats as load-bearing. A destination held by a third party is still safe because the bytes there are ciphertext and metadata, never plaintext.

Adversary 2: a full compromise of your own Cloudflare account

This is the strongest insider-style adversary, and it is your account, not the vendor. An attacker who seizes the live Cloudflare account the engine runs inside gets whatever the engine holds.

What it gets in the two-recipient posture. The operational private key and the signer private key. With the destination bytes, that operational key CAN decrypt past archives through the in-account read-back path. This is the two-recipient posture’s disclosed residual, and it is real.

What it still cannot do. It cannot recover through break-glass, because the break-glass private key is offline and was never in the account. It can use the signer key to forge a new run’s signature, but anti-rollback makes that observable to a careful recoverer, as adversary three explains.

What break-glass-only changes. The strict break-glass-only posture omits the operational recipient entirely, so a full account compromise yields no key that opens any archive. It does not cost you in-account read-back as a whole: verify-at-seal and the hourly canary both open their own run from a per-run master rather than a standing key, and an in-console break-glass restore reads your key in the browser. What stops is the unattended proof over past runs, which needs a standing key: the drill, the scheduled restore test and the retention prune defer, and attended verification replaces the unattended proof. The two postures, and how to switch between them safely, are the subject of recovery postures.

This adversary is your own account, never the vendor

The decryption residual here is a compromise of the Cloudflare account you run the engine in. The vendor holds no operational key, no signer key and no token, so the vendor cannot mount this attack. If your most-feared threat is a takeover of your own account, the break-glass-only posture removes the residual, at the cost of the unattended proof over past runs rather than of in-account read-back itself.

Adversary 3: a bucket-write forger

This is an attacker who can rewrite objects in your destination but lacks the keys. A compromised storage credential, a malicious proxy in front of the bucket, a tampering middlebox.

What it cannot forge. It cannot forge a run that a verifying reader accepts. The reader checks the root signature against the operator-supplied signer it was pinned to, and it recomputes the key commitment, the recipient-set hash, the shard hashes, the Merkle root and the per-record hashes (engine/docs/THREAT-MODEL.md, adversary 3). The capsule goes further: it binds the run-key commitment as the AES-GCM additional data, so even on the unverified byte-extraction path that skips signature checks, a swapped capsule fails authentication (engine/src/crypto/capsule.ts). A forgery would therefore have to defeat both a classical and a post-quantum signature scheme, which is the both-halves-required property the suite is built on.

What it can do. It can delete objects. That is a denial of availability, not a confidentiality or integrity break. The design does not pretend otherwise; it makes the loss loud. The completeness check surfaces a missing object rather than letting a partial archive read as whole, so an availability attack shows up as an explicit failure rather than a silent gap.

The reason forgery is closed and deletion is not comes down to where each anchor lives. The signature, the hashes and the recipient-set binding all travel inside the archive and are recomputed by the reader, so a tampered byte is caught. Object presence is a property of the destination store, which a write-capable attacker controls, so its absence can only be detected on read, not prevented at the store by downpipes itself. Pairing the destination with R2 bucket locks or S3 Object Lock is how an operator turns that detection into prevention, and the engine reports the live lock status of the bucket rather than inferring it (engine/src/dest/s3.ts, objectLockStatus).

Anti-rollback: observable by default, prevented only with the pin

A subtler move for this adversary is rollback: serving you an older, still validly-signed run in place of the current one. The freshness check is the defence, it has two levels, and it is not only offline. The offline reader is the authoritative verifier for the format, and the in-account engine runs the same check as a faithful port, so an in-account drill or restore reaches the same verdict on the same bytes.

By default the check is observable. It verifies the run log signature against the pinned signer, confirms the run agrees with the signed root, and reports whether the run is the latest for its downpipe along with the run log’s maximum index. It also rejects an internally chain-anomalous log, a duplicated index, a break in a downpipe’s prevRunId linearity, or a dangling or forked prevRunId, as a rollback regardless of whether the restored run is itself the latest (downpipe/internal/format/freshness.go, CheckFreshness and detectChainAnomaly). A careful recoverer sees that the run is not the latest and can act.

Prevention is opt-in on the offline channel. When the operator supplies a minimum high-water mark out of band, --min-runlog-index, recorded on the recovery sheet, the reader fails closed on a run log whose maximum index is below the pin (the minIndex > 0 branch in CheckFreshness). Without that pin, a rollback to an older validly-signed run is only observable there, never automatically prevented, and the pin is the one operator action that turns detection into refusal.

Against this particular adversary, the in-account side does better than that, and the page should say so rather than leave the reader with the offline picture alone. The blind restore test and the keyless attestation read the scheduler’s own live run-index counter and pass it as the minimum, so a whole-document replay of an older, validly-signed run log is refused in the console with no number written down anywhere. That works precisely because this adversary writes to the bucket and cannot reach the scheduler’s Durable Object, which is why the recovery-sheet pin remains the answer for the account-compromise adversary above rather than being made redundant by it. The coverage is not total: verify-at-seal’s Tier-0 attestation and the scheduled reconciliation run without the pin, so on those paths a replay is observed rather than refused.

Adversary 4: a malicious or compromised update

This is a bad engine update, or a compromised release channel that tries to push one.

What is refused outright. An update that is unsigned or signed by the wrong key never runs. The release-signer allowlist is pinned, and the default update path is a manual operator apply with no stored deploy token, so there is no standing credential for a hostile channel to ride (engine/docs/THREAT-MODEL.md, adversary 4).

The narrow window that is real. A malicious update that does get applied and run sees the plaintext the engine reads during a run. That is inherent to any in-account writer: to back up a source, the engine must read it. This is a narrow but genuine window, and the page states it plainly rather than waving it away. What that update still cannot do is decapsulate what break-glass protects, because the break-glass private key is not in the account for it to steal.

Why recovery is untouched. The recovery path never runs vendor code. The open on-disk format plus the offline reader recover an archive regardless of the engine version, so even a fully malicious update cannot reach into a past archive’s confidentiality or block your ability to recover it with your own key.

Adversary 5: the vendor itself

This is the most-asked question for a trust product, so it is answered directly. 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 hostile move. Ship a bad update, which is constrained exactly as adversary four describes, or withdraw assurance. Withdrawing assurance is fail-open: the assurance layer is the reporting and the proof, and its loss never gates a backup or a recovery (engine/docs/THREAT-MODEL.md, adversary 5). When assurance is withdrawn, backups keep running and recovery keeps working.

What it cannot do. The vendor cannot read your data, cannot reach into your account, holds no token, and cannot prevent your recovery. This is the structural form of no-custody, and it is the subject of the no-custody trust model.

The residual risks, stated plainly

These are the limits, drawn straight from the threat model and the cryptographic inventory. None is hidden, and each is here so an evaluator can weigh it on purpose.

The two-recipient-posture decryption residual is the first. In that posture, a full compromise of your own Cloudflare account, combined with the destination bytes, can read past archives through the operational key. Strict break-glass-only, which is what a new estate ships with, has no operational key and so has no such residual.

The in-account-writer window is the second. Any writer that runs inside your account, including a malicious update during a run, necessarily sees the plaintext the engine reads while it runs. Break-glass recoverability is unaffected, but the window is real.

Anti-rollback needs the operator pin on the paths that do not pin it for you is the third. The blind restore test and the keyless attestation pin the scheduler’s live run-index counter themselves, so a whole-document replay is refused there without any operator action. Everywhere else, including an offline recovery, the recovery-sheet pin passed as --min-runlog-index is what turns detection into refusal, and without it a rollback to an older validly-signed run is observable rather than prevented. The offline pin is also the only one of the two that survives a compromise of the account holding the counter.

The post-quantum library is not constant-time-guaranteed is the fourth. The @noble/post-quantum library used for ML-KEM-1024 and ML-DSA-87 is not guaranteed constant-time (engine/src/crypto/pq.ts). The shape of the exposure follows the posture, consistent with adversary two and the residual above. Only strict break-glass-only has zero in-account decapsulation: with no operational private key, the engine encapsulates and signs but never decapsulates a long-lived key in-account. In the two-recipient posture the engine does decapsulate the long-lived operational private key in-account on the production path (verify-at-seal on every finalise, the hourly canary, scheduled restore tests, in-console restore and the retention-prune pass), so the bound there is that it is the engine’s own operational key on the engine’s own account rather than a key the vendor holds. A constant-time native build is the documented escalation. The docs do not claim side-channel resistance.

Key-management cadence is the fifth. There is no enforced key-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 (engine/docs/security/cryptography-and-keys.md, section 3.4). A rotation schedule is an operational commitment, not a control the engine enforces. Retention auto-deletion is likewise opt-in and off by default, not on.

Standing source read access is the sixth. The engine holds standing binding-level read access to the sources you configure it to back up. It is not scoped to exactly the bytes a single backup needs; it can read its configured bindings whenever it runs. The reserved-bindings guard stops a source from naming the engine’s own credential or key bindings, so a misconfigured source cannot turn into a confused-deputy read of the engine’s secrets (engine/docs/security/input-validation-and-limits.md, section 1.8), but the read access over the configured sources themselves is standing.

Two deliberate SSRF residuals are the last, and both are accepted operator decisions rather than oversights.

Accepted residual What is allowed Why it is a deliberate decision
http://localhost for the S3 destination endpoint A plain http://localhost, http://127.0.0.1 or http://[::1] destination endpoint is permitted; every other endpoint must be https It is the local test loopback (MinIO or LocalStack) and never leaves the host; the https rule still protects every real, off-host endpoint (engine/src/dest/s3.ts, requireHttpsEndpoint)
Private and loopback webhook URLs A customer webhook URL is not blocked from private or loopback addresses An owner who points an alert at an internal relay is making an explicit choice; the URL must still be https, carry no userinfo, and not be a workers.dev host (engine/docs/security/input-validation-and-limits.md, section 10)

The webhook residual is covered in operational detail, with the exact allow-and-block rules, in webhook egress and the deliberate SSRF residual.

One honesty point about the assurance trail itself belongs in this register. The audit chain that records who did what is tamper-evident, a SHA-384 hash chain, so an edit or a truncation short of a full re-chain is detectable against an exported head hash. It is not the stronger property a single word would suggest, and it is redaction-safe for customer data but not free of identity: it deliberately carries operator identity, including the actor email, source IP, role and approver emails, so it is durable evidence of who made a change. The trail is therefore evidence about operators, not a leak of backed-up data.

The recurring property

Read the five adversaries together and one shape repeats. An adversary can disrupt assurance: it can withhold a report, delete objects to deny availability, or, as a hostile vendor, withdraw the assurance layer altogether. What no adversary on this page can do, short of the disclosed two-recipient-posture account-compromise residual, is read your data, and no adversary can stop your recovery. Confidentiality rests on a key the vendor never holds, and recoverability rests on the open format plus your offline break-glass key, with neither Cloudflare nor the vendor in the loop.

A word on integrity wording that matters here. The archive and the audit chain are tamper-evident, not the stronger thing a single careless word would imply. A rewritten archive or a rolled-back history is detected, by the recomputed hashes and the pinned signature, and detection is honest. Prevention of a physical rewrite lives in the storage layer, which is why you pair downpipes with R2 bucket locks or S3 Object Lock at the destination when you want modification prevention rather than modification detection.

Deeper detail: the freshness check, the capsule binding and the chain anomalies

What CheckFreshness verifies, in order. It decodes and verifies the run log signature against the operator-pinned signer, parses the append-only NDJSON run log, and finds the entry for the run being restored. It binds that entry to the signed root’s downpipe, then confirms the entry’s index and prevRunId agree with the signed root. It computes whether the run is the latest for its downpipe and the run log’s maximum index, rejects any internal chain anomaly, checks the maximum index against the operator pin, and last requires the run to be the latest for its downpipe. Every one of those failures returns an ExitStale error (downpipe/internal/format/freshness.go).

A check that found something, and a check that could not run, are different verdicts. This is the part an evaluator should not skim. The result carries the finding separately from whether a finding was possible at all: a run log that verified against your signer and reports the run as superseded is a measurement, whereas a run log that is absent, unreadable, unparseable, empty, missing this run, failing its signature, or disagreeing with the signed root means nothing was established about this run’s recency, and a bucket someone has rolled back or replaced looks exactly the same. Both refuse. Both were once quieter than they should have been on the engine side, where the unrunnable cases reported no rollback at all; the engine now returns the same unchecked verdict the offline reader does, so a deleted run log refuses rather than opening.

Which acknowledgement reaches which state. There are two, and the narrower one is not a general override. --allow-stale is an age word and covers only the two findings that come from a run log that verified and is internally consistent: this run is not the latest for its downpipe, and the log’s maximum index is below your --min-runlog-index pin. Every other freshness failure needs --allow-unverified-runlog, which says plainly that it waives the check rather than satisfying it. That includes the chain anomalies below, which sit on the untrustworthy-log side rather than the age side: the log verified under your signer and still contradicts itself, so it was rewritten or hand-assembled rather than appended to by the engine, and which run is latest cannot be read from it. The distinction is load-bearing against this adversary: a bucket-write forger’s cheapest move is to delete the run log rather than forge one, and an age word must not be what clears that. The exit codes page carries both acknowledgements in full.

The three chain anomalies, and the one thing that is not an anomaly. The check rejects a duplicated index (the account-global counter never reissues one), a break in a downpipe’s prevRunId linearity (an entry removed from the middle of a chain, or a rewritten chain), and a dangling or forked prevRunId (a pointer to an entry that no longer exists, or two entries claiming the same predecessor). Line order in the log is deliberately not load-bearing, because concurrent runs legitimately append out of allocation order; the whole-document signature is the integrity anchor. A per-downpipe index gap is not an anomaly either, because indices are allocated account-globally and a failed run consumes one without appending an entry (downpipe/internal/format/freshness.go, detectChainAnomaly).

Why a swapped capsule fails even unverified. 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 run-key commitment as additional data (engine/src/crypto/capsule.ts). Because that commitment is bound as the AES-GCM additional data, a capsule swapped in by a bucket-write adversary fails authentication on the byte-extraction path that skips the signature checks, not only on the fully verified path.

The destination endpoint rule, precisely. requireHttpsEndpoint rejects any destination endpoint that is not https, before any credential or byte is signed, so a misconfigured http:// endpoint cannot transmit the SigV4 credential and the archive bytes in cleartext. The single exception is a plain http://localhost, http://127.0.0.1 or http://[::1] loopback for local testing. Every credentialed request also refuses to follow a redirect, so a 3xx from the destination is treated as an error rather than a re-sent credentialed request to a third party (engine/src/dest/s3.ts).

Where this fits

This page is the adversary-by-adversary view. The custody side of the same story, what is held where and why the engine cannot read its own archives, is the no-custody trust model. The posture choice that the adversary-two residual turns on is recovery postures, which sets the two-recipient posture against strict break-glass-only.

Next steps

For how the precise wording on this page is held to (tamper-evident not the stronger word, post-quantum hybrid not a future promise, and the residuals named rather than hidden), read precise claims and honesty. For the operational detail behind the webhook SSRF residual, read webhook egress and the deliberate SSRF residual.

Last updated .