From 041c7758c187be41a6b45c548c9e66d284aaa097 Mon Sep 17 00:00:00 2001 From: Jason Ross <51939451+JMR-dev@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:13:39 -0500 Subject: [PATCH 1/2] #6 Decision: GitHub labels + abuse/rate-limit policy ADR (#18) Add docs/decisions/labels-and-abuse.md fixing concrete values that unblock #14 and inform #7: - Labels on auto-published issues (JMR-dev/LibreMail): bug-report, automated, needs-triage. #14 must create any missing. - Ingest policy: 256 KiB payload cap (413); per-IP 15/60s + 100/1h rate limits (429 + Retry-After) via Cloudflare Rate Limiting rules in Pulumi; full response-code contract (202/400/413/415/405/429/5xx). - Weekly publish job: 50 issues/run cap; serial creation, 1s spacing, Retry-After honoured, exponential backoff (base 1s, cap 60s, jitter, max 5 attempts), mark-published-on-confirm de-dup. Co-authored-by: Claude Opus 4.8 --- docs/decisions/labels-and-abuse.md | 229 +++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 docs/decisions/labels-and-abuse.md diff --git a/docs/decisions/labels-and-abuse.md b/docs/decisions/labels-and-abuse.md new file mode 100644 index 0000000..400177d --- /dev/null +++ b/docs/decisions/labels-and-abuse.md @@ -0,0 +1,229 @@ +# ADR: GitHub labels + ingest abuse / rate-limit policy + +- **Status:** Proposed (becomes Accepted on merge) +- **Date:** 2026-07-02 +- **Ticket:** [#6](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/6) +- **Unblocks:** [#14](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/14) (publish job) - **Informs:** [#7](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/7) (ingest endpoint) +- **Parent:** [JMR-dev/LibreMail#11](https://github.com/JMR-dev/LibreMail/issues/11) open question: "Target GitHub label(s); abuse / rate limiting." + +## Context + +A Cloudflare Worker (Go) exposes an HTTPS ingest endpoint that accepts user-initiated, +opt-in debug bug-report POSTs from the LibreMail Android app. Accepted reports are +best-effort scrubbed of PII, stored encrypted in Cloudflare R2, and every Friday at 17:00 +Central a scheduled job publishes any not-manually-removed report as a GitHub issue on the +app repo `JMR-dev/LibreMail`. + +Constraints that shape this decision: + +- **Single maintainer, low expected volume.** Reports are user-initiated debug submissions + only; realistic legitimate volume is single digits per week. +- **Mobile clients behind CGNAT.** Many distinct users share a small pool of public IPs via + carrier-grade NAT, so per-IP limits must be generous enough not to lock out a whole carrier. +- **IaC is Pulumi (Go).** Infrastructure (including edge rate-limit rules) is provisioned + declaratively, which favours a config-driven rate-limit mechanism over hand-rolled state. +- **Existing labels on `JMR-dev/LibreMail`** (checked 2026-07-02): `bug`, `documentation`, + `duplicate`, `enhancement`, `good first issue`, `help wanted`, `invalid`, `question`, + `wontfix`, `Infrastructure`, `Compliance`, `Release`, `donotmerge`. There is **no** + `bug-report`, `needs-triage`, or `automated` label yet, and the generic `bug` label is + reserved for maintainer-confirmed defects, so a distinct marker is needed. + +This ADR fixes concrete values so #7 and #14 can be implemented without further decisions. +It **decides** the labels; it does **not** create them (that is #14's implementation job). + +--- + +## Decision 1 - GitHub labels on auto-published issues + +Every issue created by the weekly publish job on `JMR-dev/LibreMail` is tagged with **all +three** labels below. Names are lowercase kebab-case (consistent with GitHub's built-in +`bug` / `good first issue` style and trivially queryable). + +| Label | Suggested colour | Applied | Meaning | Removed by | +| -------------- | ---------------- | ------------ | ------------------------------------------------------------------------------------------------ | ---------- | +| `bug-report` | `#e99695` | at creation | This issue originated from the app's opt-in debug bug-report ingest pipeline. Distinct from the maintainer-triaged `bug` label. Permanent origin marker. | never (permanent) | +| `automated` | `#c5def5` | at creation | Provenance: the issue was created by the weekly publish job, not written by a human. Lets the maintainer filter/automate bot-created issues. | never (permanent) | +| `needs-triage` | `#fbca04` | at creation | Workflow state: the report has not yet been reviewed/confirmed by the maintainer. | maintainer, once triaged | + +**Rationale for three labels:** `bug-report` + `automated` are permanent provenance markers +(origin and authorship); `needs-triage` is a mutable workflow state the maintainer clears. +Together they give a clean triage inbox query: + +``` +repo:JMR-dev/LibreMail is:issue is:open label:bug-report label:needs-triage +``` + +**Requirement for #14:** before (or while) creating issues, the publish job **must ensure +each label exists on `JMR-dev/LibreMail`, creating any that are missing** (idempotent +create-or-ignore via the GitHub Labels API). None of the three exist today. Suggested +colours above are non-binding; the maintainer may recolour without changing this ADR. +Label **names** are binding (they are the contract between #14's creation step and its +application step, and any maintainer saved queries). + +--- + +## Decision 2 - Ingest endpoint abuse / rate-limit policy (for #7) + +### 2.1 Maximum request / payload size + +- **Hard limit: 256 KiB (262,144 bytes) for the request body.** Requests larger than this + are rejected with **413**. +- **Enforce in two places** (do not trust a single signal): + 1. Fast path: if `Content-Length` is present and exceeds the limit, reject with 413 + **before** reading the body. + 2. Hard cap while reading: wrap the body reader so it stops and returns 413 once + 262,144 bytes have been read (covers missing/chunked/lying `Content-Length` and + protects against memory exhaustion). + +**Justification:** A debug report is JSON metadata (app/OS version, device model) plus logs +and stack traces. Realistic reports are a few KiB to low tens of KiB. 256 KiB leaves +generous headroom (~250k characters, thousands of log lines) while being far too small to be +useful as an abuse/exfiltration vector or to bloat R2. **Note for #14:** GitHub issue bodies +are capped at **65,536 characters**, so #14 must truncate or attach when formatting a report +whose rendered body would exceed that; the 256 KiB ingest cap is intentionally larger than +the GitHub cap and is not the same limit. + +### 2.2 Rate limit per client IP + +| Rule | Threshold | Action | Purpose | +| --------------- | -------------------------------------- | ------------------------------------- | ------------------------------------ | +| Burst | > 15 requests / 60 s per IP | 429; block that IP for 10 minutes | Stop a script hammering the endpoint | +| Sustained | > 100 requests / 1 h per IP | 429; block that IP for 1 hour | Catch slower, sustained abuse | +| Global (optional, defense-in-depth) | > 1,000 accepted reports / 1 h across all IPs | 503 + alert the maintainer | Distributed abuse bypassing per-IP limits | + +**Justification for the (generous) per-IP numbers:** a legitimate user submits once, maybe +retrying a few times. 15/min and 100/hr are far above that, deliberately, because CGNAT means +many real users can share one public IP; tighter limits risk blocking a whole mobile carrier. +A malicious client still does thousands/min, so 15/min blocks abuse decisively. Numbers are +starting points; the maintainer should tighten if abuse appears (see Consequences). + +### 2.3 Mechanism + +**Recommended (primary): Cloudflare Rate Limiting rules, provisioned via Pulumi.** + +- Runs at the edge **before** the Worker executes: rejected requests cost no Worker + invocation/CPU and never touch application code - strong volumetric protection, cheaply. +- Declarative, so it lives in the existing Pulumi (Go) IaC alongside the rest of the infra; + no custom counter state to write or maintain. +- **Trade-offs:** requires the Rate Limiting rules feature on the account's Cloudflare plan; + counting is IP-based and therefore coarse under CGNAT (accepted, per 2.2). + +**Alternatives (documented, not chosen for v1):** + +- *Workers Rate Limiting binding* (`env.RATE_LIMITER.limit({ key })`) - in-Worker, use if + edge Rate Limiting rules are unavailable on the plan. Runs inside the Worker (costs an + invocation) but needs no external store. +- *Durable Object token bucket* - strongly consistent, supports custom keys (e.g. per + app-supplied token rather than IP). Most accurate but adds a DO round-trip, DO cost, and + code complexity. **Not** justified for this low-volume, single-maintainer project up front; + revisit only if per-IP rules prove inadequate. +- *Workers KV counter* - **rejected**: KV is eventually consistent, so counts undercount + across edge locations, making it unreliable for enforcing a rate limit. + +Regardless of the rate-limit mechanism, the **Worker always enforces the size cap (2.1) and +schema validation (2.4)** itself - those cannot be expressed as an edge rate rule. + +### 2.4 Response-code contract + +| Status | When | Notes / headers | +| ----------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| **202 Accepted** | Well-formed report accepted, scrubbed, and stored for the weekly publish run | Body `{"status":"accepted"}`. Async pipeline, so 202 (not 200). | +| **400 Bad Request** | Not valid JSON, missing/invalid required fields, or fails schema validation | Body `{"error":""}`. Never echo request contents. | +| **413 Payload Too Large** | Body exceeds 256 KiB (per 2.1) | - | +| **415 Unsupported Media Type**| `Content-Type` is not `application/json` (may be folded into 400 if #7 prefers) | - | +| **405 Method Not Allowed** | Any method other than POST | Send `Allow: POST`. | +| **429 Too Many Requests** | Rate limit exceeded (per 2.2) | **Must** send `Retry-After: `. | +| **500 Internal Server Error** | Unexpected server error | Generic body; log internally. | +| **503 Service Unavailable** | Storage (R2) unavailable, or global shed (2.2) | May send `Retry-After`. | + +**Client retry contract** (the app honours this): + +- **429** - wait for `Retry-After`, then retry. +- **5xx** - retry with exponential backoff + jitter, bounded attempts. +- **400 / 413 / 415** - permanent; do **not** retry (the request is malformed/too large). + +Error responses must be small and must not reflect request contents or reveal whether a +specific IP is individually blocked (avoid info leak / log injection). + +--- + +## Decision 3 - Weekly publish job policy (for #14) + +### 3.1 Per-run issue cap + +- **Maximum 50 issues created per weekly run.** If more than 50 reports are pending, publish + the **oldest 50**, leave the remainder pending for the next run, and **alert the + maintainer** that the cap was hit. + +**Justification:** 50 is far above realistic legitimate weekly volume, so it never throttles +normal flow, but it caps blast radius if abuse slips through or a bug produces a surge - +better to throttle and alert than to flood the app repo with hundreds of auto-issues. Draining +oldest-first over subsequent runs is the safer failure mode; the maintainer can raise the cap +or purge abusive reports. + +### 3.2 GitHub API pacing, backoff, and retry + +GitHub's authenticated **primary** limit is 5,000 requests/hour; **secondary** limits cap +content creation (guidance: serial requests, ~1 s between mutations, and roughly 80 +content-creating requests/min / 500/hour). The policy below sits comfortably under all of +these (50 issues at 1 s spacing is ~50 s of wall-clock; awaiting network I/O does not consume +Worker CPU time, so it fits within the scheduled-event budget). + +- **Serialize** issue creation - one at a time, **no concurrency**. +- **Space successful creates by at least 1 second.** +- **Honour `Retry-After` exactly** whenever a 403/429 response includes it (overrides the + computed backoff below). +- **On 403/429 with `x-ratelimit-remaining: 0`** - wait until the `x-ratelimit-reset` epoch, + then retry. +- **On a secondary-rate-limit 403 without `Retry-After`** - wait a **minimum of 60 s**, then + apply the exponential schedule below. +- **On 5xx / network errors** - exponential backoff `min(60s, 1s * 2^attempt)` with **full + jitter**, **max 5 attempts per issue**. Schedule: ~1s, 2s, 4s, 8s, 16s (capped at 60s), + each jittered. +- **On attempt exhaustion** - leave that report **pending** (do not mark published), log + + alert, and continue with the next report. Never abort the whole run for one failure. +- **Idempotency / de-dup** - a report is marked published **only after a confirmed 201 + Created**. On an ambiguous failure (e.g. timeout after POST) the report stays pending and + #14's own de-dup mechanism (a stored per-report published marker / issue URL) must prevent a + duplicate on the next run. (Detailed de-dup design is #14's scope; this ADR only fixes the + "mark published only on confirmed success" rule.) + +--- + +## Consequences + +**Positive** + +- #7 and #14 have concrete, implementable numbers with no open decisions. +- Edge rate limiting (Cloudflare rules via Pulumi) protects the Worker cheaply and fits the + existing IaC; the Worker still owns size + schema validation. +- Three provenance/state labels give the maintainer a clean, queryable triage inbox and keep + auto-reports distinct from the human-triaged `bug` label. +- The per-run cap + backoff make the publish job safe against surges and GitHub secondary + limits, and safe to re-run (de-dup, mark-on-confirm). + +**Negative / limitations** + +- IP-based rate limiting is coarse under mobile CGNAT: shared IPs mean the per-IP limits are + deliberately loose, and a burst from one carrier could still occasionally throttle a real + user. Mitigated by generous thresholds + short block durations; revisit with per-app-token + limits (Durable Object) if needed. +- The 256 KiB ingest cap (and the downstream 65,536-char GitHub issue-body cap) may truncate + extremely verbose reports. Acceptable for a debug pipeline; #14 truncates/attaches. +- The 50/run cap means a large legitimate backlog would drain over several weeks. Acceptable + and safer than flooding; the maintainer is alerted when the cap is hit. + +**Follow-ups** + +- Revisit all thresholds after observing real traffic. +- Consider an app-supplied shared secret / attestation on the ingest request to reduce + anonymous abuse (feeds #7), and a Durable Object token bucket if IP limits prove inadequate. + +## Values the maintainer should confirm at review + +- **Three labels** (`bug-report`, `automated`, `needs-triage`) vs. a leaner set - is + `needs-triage` wanted at creation, or should it be applied manually? +- **256 KiB** payload cap. +- **Per-IP limits** (15/min, 100/hr) and whether the optional global 1,000/hr shed is wanted + in v1. +- **50 issues/run** cap and where the "cap hit" alert should go. -- 2.47.3 From 389379921da622b1fafebffb11d184a5a49468d6 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 2 Jul 2026 13:14:04 -0500 Subject: [PATCH 2/2] Add encryption ADR: Worker-side AES-256-GCM + Secrets Store key custody 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 --- docs/decisions/encryption.md | 392 +++++++++++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 docs/decisions/encryption.md diff --git a/docs/decisions/encryption.md b/docs/decisions/encryption.md new file mode 100644 index 0000000..1aad882 --- /dev/null +++ b/docs/decisions/encryption.md @@ -0,0 +1,392 @@ +# 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](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/5) — Decision: encryption scheme + key custody (Cloudflare Secret Manager) +- **Unblocks:** [#9](https://github.com/JMR-dev/LibreMail-Bug-Report-Ingest/issues/9) — encrypted-at-rest R2 storage +- **Related:** [JMR-dev/LibreMail#11](https://github.com/JMR-dev/LibreMail/issues/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-`PUT`ting 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](#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): + +```go +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): + +```go +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): + +```json +{ + "active": 2, + "keys": { + "1": "", + "2": "" + } +} +``` + +- Each `keys[]` 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): + +```toml +secrets_store_secrets = [ + { binding = "BUGREPORT_ENC_KEYRING", 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": ""` 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 + +- Cloudflare R2 — Data security (default at-rest encryption): https://developers.cloudflare.com/r2/reference/data-security/ +- Cloudflare R2 — Use SSE-C: https://developers.cloudflare.com/r2/examples/ssec/ +- Cloudflare Secrets Store — Workers integration (`secrets_store_secrets`, `await env.BINDING.get()`): https://developers.cloudflare.com/secrets-store/integrations/workers/ +- Cloudflare Workers — Secrets: https://developers.cloudflare.com/workers/configuration/secrets/ +- TinyGo — Packages supported by TinyGo (stdlib status incl. `crypto/aes`, `crypto/cipher`, `crypto/rand`): https://tinygo.org/docs/reference/lang-support/stdlib/ +- NIST SP 800-38D — AES-GCM (nonce/usage guidance): https://csrc.nist.gov/pubs/sp/800/38/d/final -- 2.47.3