#5 Decision: encryption scheme + key custody #19

Merged
JMR-dev merged 1 commits from ticket-5-encryption-adr into main 2026-07-02 18:17:16 +00:00
JMR-dev commented 2026-07-02 18:14:41 +00:00 (Migrated from github.com)

Summary

Adds docs/decisions/encryption.md — the ADR for how scrubbed bug reports are encrypted at rest in R2 and how the key is held. Decision/ADR ticket only (no implementation code); it unblocks #9.

Decision

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

  • Why this over the alternatives: R2 default at-rest encryption (Cloudflare-managed keys) leaves plaintext readable by anyone with bucket read access — fails the requirement. R2 SSE-C encrypts at rest under a customer key but still sends plaintext + key to the R2 service on every request and gives us no integrity/rotation control. Worker-side AEAD means R2 only ever stores opaque ciphertext (never plaintext or the key), gives us GCM integrity/tamper-evidence, and makes rotation data-loss-free. Reports can carry residual PII after best-effort scrubbing, so confidentiality against the storage boundary matters.

What #9 can code against

  • Scheme: AES-256-GCM, 12-byte random nonce per object, 128-bit tag, header authenticated as AAD.
  • Object layout (R2 body): magic "LMB1"(4) || format_version(1) || key_id(2, BE) || nonce(12) || ciphertext(N) || tag(16).
  • Key custody: versioned JSON keyring ({active, keys{ver: base64-32B}}) in Cloudflare Secrets Store, bound to both Workers; read at runtime via await env.BUGREPORT_ENC_KEYRING.get(); never logged/returned.
  • Weekly publish: recommended as a Cron-Trigger Worker sharing the same binding so the key never leaves Cloudflare (GitHub Actions decryption explicitly discouraged — it would widen custody).
  • Rotation: add a new key version + bump active; the key_id in each object header selects the right version, so old objects keep decrypting; never drop a version still referenced.

Implementation note flagged for #9

TinyGo's crypto/aes and crypto/cipher currently fail their test suites, so the ADR recommends doing AES-GCM via the Workers host Web Crypto API (SubtleCrypto) rather than pure-Go crypto. The wire format is identical either way.

Open questions for the maintainer (see ADR "Open questions")

  1. Weekly publisher = Cron Worker (assumed) or GitHub Action?
  2. Crypto provider = host SubtleCrypto (recommended) or pure-Go once TinyGo is verified?
  3. Keyring shape/binding name confirmation.
  4. Are R2 objects deleted after publication (bounds rotation burden)?

Notes

  • Per ticket scope this PR does not edit the README (to avoid conflicting with the parallel bootstrap PR). The ticket's acceptance criteria mention linking the ADR from the README — suggest adding a one-line link under the README "Status" section once the bootstrap PR lands (e.g. Encryption/key-custody decision: docs/decisions/encryption.md).

Closes #5

## Summary Adds `docs/decisions/encryption.md` — the ADR for how scrubbed bug reports are encrypted at rest in R2 and how the key is held. Decision/ADR ticket only (no implementation code); it unblocks #9. ## Decision **Worker-side AES-256-GCM authenticated encryption, applied in the Worker before the object is written to R2**, with the key held as a versioned keyring in **Cloudflare Secrets Store**. - **Why this over the alternatives:** R2 default at-rest encryption (Cloudflare-managed keys) leaves plaintext readable by anyone with bucket read access — fails the requirement. R2 SSE-C encrypts at rest under a customer key but still sends plaintext + key to the R2 service on every request and gives us no integrity/rotation control. Worker-side AEAD means **R2 only ever stores opaque ciphertext (never plaintext or the key)**, gives us GCM integrity/tamper-evidence, and makes rotation data-loss-free. Reports can carry residual PII after best-effort scrubbing, so confidentiality against the storage boundary matters. ## What #9 can code against - **Scheme:** AES-256-GCM, 12-byte random nonce per object, 128-bit tag, header authenticated as AAD. - **Object layout (R2 body):** `magic "LMB1"(4) || format_version(1) || key_id(2, BE) || nonce(12) || ciphertext(N) || tag(16)`. - **Key custody:** versioned JSON keyring (`{active, keys{ver: base64-32B}}`) in Cloudflare Secrets Store, bound to both Workers; read at runtime via `await env.BUGREPORT_ENC_KEYRING.get()`; never logged/returned. - **Weekly publish:** recommended as a Cron-Trigger Worker sharing the same binding so the key never leaves Cloudflare (GitHub Actions decryption explicitly discouraged — it would widen custody). - **Rotation:** add a new key version + bump `active`; the `key_id` in each object header selects the right version, so old objects keep decrypting; never drop a version still referenced. ## Implementation note flagged for #9 TinyGo's `crypto/aes` and `crypto/cipher` currently fail their test suites, so the ADR recommends doing AES-GCM via the Workers host **Web Crypto API (SubtleCrypto)** rather than pure-Go crypto. The wire format is identical either way. ## Open questions for the maintainer (see ADR "Open questions") 1. Weekly publisher = Cron Worker (assumed) or GitHub Action? 2. Crypto provider = host SubtleCrypto (recommended) or pure-Go once TinyGo is verified? 3. Keyring shape/binding name confirmation. 4. Are R2 objects deleted after publication (bounds rotation burden)? ## Notes - Per ticket scope this PR does **not** edit the README (to avoid conflicting with the parallel bootstrap PR). The ticket's acceptance criteria mention linking the ADR from the README — suggest adding a one-line link under the README "Status" section once the bootstrap PR lands (e.g. `Encryption/key-custody decision: docs/decisions/encryption.md`). Closes #5
Sign in to join this conversation.