Files
Jason RossandClaude Opus 4.8 e93b6a2266 Add encryption ADR: Worker-side AES-256-GCM + Secrets Store key custody (#19)
Documents the decision for #5: Worker-side authenticated encryption (AES-256-GCM) applied in the Worker before writing to R2, so R2 never receives plaintext or the key. Key material is held as a versioned keyring in Cloudflare Secrets Store (shared by the ingest and weekly-publish Workers). Rotation is data-loss-free via a key-id/version in each object header plus retained old key versions. Unblocks #9.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:17:15 -05:00

19 KiB

ADR 0001: Encryption scheme and key custody for R2 bug-report objects

  • Status: Accepted
  • Date: 2026-07-02
  • Deciders: Maintainer (single-maintainer project)
  • Ticket: #5 — Decision: encryption scheme + key custody (Cloudflare Secret Manager)
  • Unblocks: #9 — encrypted-at-rest R2 storage
  • Related: JMR-dev/LibreMail#11 (open question: "Encryption scheme + key custody for R2 objects")

Context

A Cloudflare Worker (Go, compiled with TinyGo to Wasm) ingests LibreMail debug bug-reports over HTTPS, best-effort scrubs PII, and stores each scrubbed report in a Cloudflare R2 bucket. A scheduled job later reads the stored reports, decrypts them, and publishes each as a GitHub issue on the LibreMail repo. Secrets live in Cloudflare Secrets Store (a.k.a. Cloudflare Secret Manager). There is a single maintainer and the volume is low (individual opt-in bug reports, published weekly).

Why encryption at rest is not enough by default: PII scrubbing is explicitly best-effort. Reports can contain residual PII (log fragments, stack traces, free-text descriptions, device identifiers, e-mail-shaped strings the scrubber missed). We therefore need the stored objects to be unreadable to anyone who obtains read access to the bucket but who does not hold the maintainer's key.

Threat model

In scope — what we defend against:

  1. Bucket-read compromise. A leaked or over-scoped R2 API token, an S3-credential leak, a bucket misconfiguration, or an accidentally public bucket. An attacker can GET/LIST objects but does not have the encryption key.
  2. Storage-layer / at-rest disclosure. Access to the raw stored bytes (backups, disks, the R2 storage service) without the key.
  3. Object tampering / substitution. An attacker who can write to the bucket swaps or mutates an object to corrupt or spoof a published issue.

Out of scope — explicitly not defended against (documented so we do not overclaim):

  • A fully compromised or malicious Cloudflare platform. The Worker's compute runs on Cloudflare and the key is held in Cloudflare Secrets Store, so Cloudflare-the-platform is necessarily trusted with plaintext at processing time. Defending against the compute provider itself would require the app to never possess the key on Cloudflare (e.g. decrypt only on the maintainer's own hardware) and is not a goal here.
  • A compromised Worker isolate at runtime (it legitimately holds plaintext and the key).
  • The published GitHub issues themselves (post-publication confidentiality is a separate concern governed by scrubbing quality and repo visibility).

The meaningful, realistic win is category 1/2/3: the object bytes at rest are opaque and tamper-evident to anyone without the key.


Options evaluated

Option A — R2 default encryption at rest (Cloudflare-managed keys)

R2 encrypts all objects at rest with AES-256 automatically; keys are managed by Cloudflare. No code, nothing to configure.

  • Pros: Zero effort, transparent, always on, no key to lose.
  • Cons: Cloudflare holds the keys, and any principal with bucket read access reads plaintext (this is exactly threat #1). It protects only against raw-disk theft, which is not our primary threat. Does not meet the requirement.

Option B — R2 SSE-C (server-side encryption, customer-provided key)

R2 supports SSE-C: the client supplies a 256-bit key on each PUT/GET; R2 encrypts with it and removes the key from memory after the operation (Cloudflare cannot recover objects without the key). Available via the Workers and S3 APIs.

  • Pros: Objects at rest are encrypted under a key Cloudflare does not retain; protects against threat #1/#2. Very little code.
  • Cons:
    • Plaintext and the key are sent to the R2 service on every request — R2 performs the crypto server-side. The plaintext-exposure surface at the storage boundary is larger than Option C, and you are trusting R2 to actually drop the key each time.
    • We do not control the ciphertext format, the integrity binding, or key-versioning; we inherit R2's behavior. Rotation means re-PUTting objects under a new key.
    • The key must still be held by the caller (the Worker) anyway — so custody is no simpler than Option C, but we get less control.
    • Coupled to R2/Cloudflare; not portable.

Option C — Worker-side authenticated encryption before write (chosen)

The Worker encrypts each scrubbed report with AES-256-GCM before writing to R2, using key material from Cloudflare Secrets Store. Only ciphertext is ever handed to R2.

  • Pros:
    • R2 never receives plaintext or the key — it only ever stores opaque bytes. Smallest storage-boundary exposure of the three options. Fully covers threats #1/#2.
    • Authenticated encryption: GCM's tag gives integrity + tamper detection (threat #3); additional authenticated data (AAD) binds the format and key version so objects cannot be silently downgraded or transplanted.
    • We own the format, key-versioning, and rotation → clean rotation without data loss.
    • Portable, standard primitive; not locked to any R2/Cloudflare crypto feature.
    • Defense-in-depth layered on top of best-effort scrubbing.
  • Cons:
    • We must manage a key and its rotation (mitigations below).
    • Key loss = permanent data loss for not-yet-published reports (mitigated by an offline keyring backup).
    • Must implement AES-GCM in the TinyGo/Wasm environment (see the crypto-provider note).

Terminology. The ticket calls this "envelope encryption." Strictly, envelope encryption means generating a per-object data key (DEK) and wrapping it with a key encryption key (KEK). At this scale we adopt the simpler equivalent: a single active AES-256 data key selected from a versioned keyring, applied directly with AEAD. The true per-object-DEK variant is discussed under Alternatives considered; it is not needed here and adds moving parts.


Decision

Adopt Option C: Worker-side AES-256-GCM authenticated encryption, applied in the Worker before the object is written to R2, with the key held in Cloudflare Secrets Store as a versioned keyring.

Rationale: it is the only option where the R2 service never sees plaintext or the key, it gives us cryptographic integrity we control, and it makes key rotation a first-class, data-loss-free operation. Given the residual-PII sensitivity, that control is worth the modest key-management burden. Option A fails the requirement outright; Option B meets confidentiality but with a larger plaintext-exposure surface, no integrity guarantees we own, and worse rotation ergonomics, for no custody saving.


Concrete scheme (what #9 implements)

Primitive

  • Algorithm: AES-256-GCM (AEAD — confidentiality + integrity in one primitive).
  • Key size: 256-bit (32 bytes).
  • Nonce/IV: 96-bit (12 bytes), generated fresh per object from a CSPRNG. Never reused under the same key. (At this volume we are astronomically far from the ~2^32 random-nonce birthday bound for a single key; rotation further caps messages per key.)
  • Auth tag: 128-bit (16 bytes), the standard GCM tag. This is what provides integrity.
  • Additional authenticated data (AAD): the object header bytes (magic + format version
    • key id). AAD is authenticated but not encrypted; binding it means an attacker cannot flip the key id, downgrade the format, or splice a body under a different header without failing authentication.

Crypto provider (TinyGo/Wasm constraint — important)

The Worker is Go compiled with TinyGo → Wasm. As of this writing, TinyGo's crypto/aes and crypto/cipher are importable but fail their test suites (unimplemented reflection paths), so the pure-Go AES-GCM implementation is not safe to rely on. crypto/rand is supported.

Recommendation: perform AES-256-GCM via the Workers runtime's Web Crypto API (crypto.subtle.encrypt / crypto.subtle.decrypt with { name: "AES-GCM", iv, additionalData, tagLength: 128 }) and draw random bytes from crypto.getRandomValues, called through JS interop from the Go/Wasm Worker. This is the Cloudflare-idiomatic path and avoids shipping a questionable pure-Go cipher into Wasm.

Crucially, the on-disk wire format below is identical whether encryption is done via SubtleCrypto or a Go AEAD — both produce/consume ciphertext || 16-byte-tag. So #9 may choose either provider without changing the object format; SubtleCrypto is the recommended default. (Confirm current TinyGo status at implementation time; if pure-Go GCM has become reliable, it is a drop-in alternative.)

Stored R2 object layout

Each R2 object body is a single self-describing binary frame:

 offset  size  field
 ------  ----  -------------------------------------------------------------
   0      4    magic            = ASCII "LMB1"  (0x4C 0x4D 0x42 0x31)
   4      1    format_version   = 0x01
   5      2    key_id           = uint16, big-endian (keyring version used)
   7     12    nonce            = 96-bit random IV
  19      N    ciphertext       = AES-256-GCM(plaintext)
  19+N   16    auth_tag         = 128-bit GCM tag (appended by Seal/encrypt)
  • Header = bytes [0, 7) = magic || format_version || key_id. The header is passed as the AAD to GCM (it is stored in the clear but is authenticated).
  • The nonce is stored in the frame so the reader can supply it to GCM; GCM authenticates the nonce implicitly (a modified nonce produces a wrong tag).
  • With Go's crypto/cipher AEAD and with SubtleCrypto alike, the tag is appended to the ciphertext, so ciphertext || auth_tag is one contiguous blob (N + 16 bytes).

Encrypt (pseudocode; illustrative, not committed code):

header := []byte{'L','M','B','1', 0x01}
header = append(header, byte(keyID>>8), byte(keyID))   // magic|ver|key_id  (AAD)

nonce := random(12)                                    // CSPRNG, unique per object
ct := aesGCM(activeKey).Seal(nil, nonce, plaintext, header)   // ct = ciphertext||tag

object := append(append(header, nonce...), ct...)      // full R2 body
// PUT object to R2 at key derived from a random report id

Decrypt (pseudocode):

magic, ver, keyID := object[0:4], object[4], be16(object[5:7])
require(magic == "LMB1" && ver == 0x01)
header := object[0:7]                 // AAD
nonce  := object[7:19]
ctTag  := object[19:]                 // ciphertext||tag

key := keyring[keyID]                 // hard-fail if version unknown
plaintext, err := aesGCM(key).Open(nil, nonce, ctTag, header)
require(err == nil)                   // auth failure => reject (tamper/corruption)

Integrity

Provided entirely by AES-GCM: any modification to the ciphertext, the nonce, or the authenticated header causes Open/decrypt to fail. No separate HMAC is needed. Callers must treat a decryption/authentication error as a hard failure (skip + alert), never as "publish what we have."

R2 custom metadata (optional, operational)

For at-a-glance ops (e.g. dashboards) it is fine to mirror key_id and a report id into R2 custom metadata, but note that R2 custom metadata is neither encrypted nor authenticated. Therefore: never put anything sensitive there, and treat it as untrusted — the authoritative key_id is the one inside the AAD-authenticated header, not the metadata copy.


Key custody

Where the key lives

The key material is a versioned keyring stored as a single account-level secret in Cloudflare Secrets Store, bound to both the ingest Worker and the weekly publish job. An account-level secret can be bound to multiple Workers, so both share one custody point.

Keyring secret value (JSON):

{
  "active": 2,
  "keys": {
    "1": "<base64-std of 32 random bytes>",
    "2": "<base64-std of 32 random bytes>"
  }
}
  • Each keys[<version>] is 32 random bytes (AES-256), base64-encoded.
  • active is the version new objects are encrypted under.
  • Version numbers are the key_id written into the object header (uint16 range; ample).

Suggested binding name: BUGREPORT_ENC_KEYRING.

wrangler binding (illustrative):

secrets_store_secrets = [
  { binding = "BUGREPORT_ENC_KEYRING", store_id = "<store-id>", secret_name = "bugreport-enc-keyring" }
]

How the Worker retrieves it at runtime

Secrets Store secrets are read asynchronously through the env binding:

raw := await env.BUGREPORT_ENC_KEYRING.get()   // returns the secret string
keyring := parseJSON(raw)                        // {active, keys{ver: base64}}
// decode base64 -> map[uint16][32]byte; select keyring.keys[keyring.active] to encrypt

Handling rules:

  • Decode once and hold the parsed keyring in memory only for the isolate/request lifetime.
  • Never log it, include it in a response body, put it in an error message, or write it to R2. Redact it from any structured logging.
  • Fetching happens inside the request/scheduled handler (the get() is async), not at module top-level.

How the weekly publish job decrypts

Recommended: implement the weekly publisher as a Cloudflare Worker on a Cron Trigger that shares the same BUGREPORT_ENC_KEYRING binding. It lists R2 objects, decrypts each in-Worker (selecting the key by the header's key_id), creates the GitHub issue, and optionally deletes/tombstones the published object. The key never leaves Cloudflare. The GitHub credential it needs to open issues is a separate Secrets Store secret.

Not recommended: running decryption in a GitHub Actions job. That requires copying the keyring into GitHub Actions secrets, widening key custody to a second provider and enlarging the attack surface, for no benefit. (Note: using GitHub Actions to deploy the Workers is fine and unrelated — the concern is only about where decryption runs.)

Access control and backup

  • Restrict Secrets Store access to the maintainer via Cloudflare RBAC; enable audit logging.
  • Critical: losing the keyring means permanent, unrecoverable loss of all not-yet-published reports. Keep an encrypted offline backup of the keyring (e.g. a password manager or hardware-backed store), updated whenever a new version is added.

Key rotation

Rotation must not lose access to already-stored objects. This is handled by the key_id in each object header plus a keyring that retains old versions.

To rotate (routine):

  1. Generate a new 32-byte key from a CSPRNG (offline).
  2. Add it to the keyring under the next version number and set active to it, e.g. add "3": "<base64>" and set "active": 3.
  3. Update the single Secrets Store secret (one atomic write via dashboard/API).
  4. Allow propagation. Workers may cache a secret for the isolate's lifetime, so a new version fully takes over after isolates recycle (or after a redeploy, which forces it).

After rotation, new objects are written with key_id = 3; existing objects keep their original key_id (1 or 2) and still decrypt, because those versions remain in the keyring.

Rules:

  • Never remove a key version while any stored object still references it. Removing a version that an object was encrypted under makes that object permanently undecryptable.
  • Because reports are short-lived (stored, then published weekly and removed), a superseded key can be safely retired roughly a week or two after it stops being active — once no object references it. This bounds the rotation/retirement burden.
  • Optional explicit retirement / re-key: run a one-off pass — GET each object, Open with its old key, re-Seal under the current active key (new nonce, updated key_id), PUT back — then drop the retired version from the keyring.

Compromise response: if a key version is suspected compromised, add-and-activate a fresh version immediately so new writes are protected, then run the re-key pass above over existing objects and remove the compromised version. The short object lifetime keeps this window small.


Consequences

Positive

  • Objects in R2 are opaque ciphertext; R2 never receives plaintext or the key. Anyone with only bucket read access (leaked/over-scoped R2 token, misconfig, accidental public bucket) gets nothing usable.
  • GCM gives integrity and tamper-evidence; AAD binds format + key version, preventing downgrade/transplant.
  • Defense-in-depth over best-effort PII scrubbing.
  • Rotation is data-loss-free via the versioned keyring + key_id header.
  • One custody system (Cloudflare Secrets Store) shared by both Workers; portable primitive.

Negative / costs

  • Key loss = permanent loss of not-yet-published reports (mitigated by offline keyring backup).
  • Must implement AES-GCM in TinyGo/Wasm; recommended path is host SubtleCrypto, which adds a little JS interop.
  • Slight extra Worker CPU per report (negligible at this volume).
  • Does not defend against a malicious Cloudflare platform (out of scope, by design).
  • Unauthenticated R2 custom metadata (if used) must be treated as non-sensitive and untrusted.
  • Rotation is a manual maintainer responsibility.

Open questions to confirm at review

  1. Where does the weekly publisher run? Cron-Trigger Worker (recommended — key stays in Cloudflare) vs GitHub Action (would widen custody). This ADR assumes the Cron Worker.
  2. Crypto provider: host SubtleCrypto (recommended) vs pure-Go AES-GCM once TinyGo support is verified reliable.
  3. Keyring shape/binding: single JSON keyring secret (this ADR) vs one binding per version; confirm the binding name (BUGREPORT_ENC_KEYRING).
  4. Object lifecycle: are R2 objects deleted after publication? Confirming this (and possibly an R2 lifecycle rule) bounds rotation/retirement work. Likely its own ticket.

Alternatives considered

  • True per-object envelope encryption (DEK + KEK). Generate a random 256-bit DEK per report, encrypt the payload with the DEK, and wrap the DEK with a KEK from Secrets Store; store the wrapped DEK in the frame. Benefits: each object has an isolated key, and a single-use DEK makes payload nonce handling trivial. Costs: more bytes and an extra wrap/unwrap per object. Not worth it here — the volume is tiny and single-key AEAD with a versioned keyring is simpler and equally safe at this scale. The frame's format_version leaves room to adopt this later if requirements change.
  • AES-KW / RFC 3394 for key wrapping (if the DEK variant were adopted): rejected in favor of AES-GCM wrapping to stay on primitives available via SubtleCrypto.

References