#9 Encrypted-at-rest R2 storage for scrubbed reports

Implement the storage path: for each accepted report, scrub PII (#8),
encrypt with AES-256-GCM (ADR #5), and write only ciphertext to R2, wired
in as the real ingest Sink replacing NopSink.

- internal/crypto: AES-256-GCM in the exact ADR #5 wire format
  (magic "LMB1" || version || key_id BE16 || nonce(12) || ct || tag(16);
  the 7-byte header is the GCM AAD). Provider-independent framing shared by
  a host crypto/aes+crypto/cipher impl (tests, devserver) and a Wasm
  SubtleCrypto impl (syscall/js, //go:build js && wasm) per the TinyGo
  constraint; both produce byte-identical frames. Versioned keyring with
  key_id rotation; ParseKeyring reads the Secrets Store JSON secret.
- internal/storage: ObjectStore interface with an in-memory fake (tests,
  devserver) and a Wasm R2Store (syumai/workers R2 binding). Sink ties
  scrub -> Seal -> Put under a unique reports/<ts>-<rand> key. WorkerSink
  loads the keyring from Secrets Store (BUGREPORT_ENC_KEYRING), cached for
  the isolate lifetime.
- handler.New now takes an injectable ingest.Sink; the Worker uses the real
  R2/Secrets-Store sink, the devserver a memory + throwaway-key sink.
- wrangler.jsonc: add REPORTS_BUCKET (R2) and BUGREPORT_ENC_KEYRING
  (Secrets Store) bindings.

Tests (host, no TinyGo): encrypt/decrypt roundtrip; ciphertext != plaintext;
wrong key + tamper (ct/tag/nonce/header-AAD) fail; exact wire layout plus a
known-answer vector; key_id rotation with retained keys; full sink path (PII
scrubbed then encrypted, readback requires the key and yields the scrubbed
content). Existing ingest/handler behavior preserved (202 on valid POST).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:08:21 -05:00
committed by Jason Ross
co-authored by Claude Opus 4.8
parent 3d320a8cea
commit bd7fe21d97
15 changed files with 1520 additions and 9 deletions
+20 -1
View File
@@ -5,6 +5,11 @@
// so `go run ./cmd/devserver` works with the standard Go toolchain and exercises
// the exact same handler that the deployed Wasm Worker serves. It listens on
// :8787 by default (matching wrangler dev's default port); override with ADDR.
//
// The ingest path is wired with the real scrub+encrypt storage Sink (#9) backed
// by an in-memory object store and a throwaway per-run AES-256 key, so a POST
// /v1/reports exercises the full pipeline locally. Stored objects live only for
// the process lifetime.
package main
import (
@@ -12,7 +17,9 @@ import (
"net/http"
"os"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/crypto"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/handler"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/storage"
)
func main() {
@@ -21,8 +28,20 @@ func main() {
addr = ":8787"
}
// A throwaway keyring: a single random key generated at startup. Reports are
// scrubbed, encrypted under it, and held in memory; nothing is persisted.
key, err := crypto.GenerateKey()
if err != nil {
log.Fatalf("devserver: generate key: %v", err)
}
keyring, err := crypto.NewKeyring(1, map[uint16][]byte{1: key})
if err != nil {
log.Fatalf("devserver: build keyring: %v", err)
}
sink := storage.NewSink(storage.NewMemoryStore(), keyring)
log.Printf("devserver listening on %s (try GET / and GET /healthz)", addr)
if err := http.ListenAndServe(addr, handler.New()); err != nil {
if err := http.ListenAndServe(addr, handler.New(sink)); err != nil {
log.Fatalf("devserver: %v", err)
}
}
+401
View File
@@ -0,0 +1,401 @@
package crypto
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"strings"
"testing"
)
// fixedKey returns a deterministic 32-byte key whose bytes are seed+i, for
// reproducible test vectors (never used outside tests).
func fixedKey(seed byte) []byte {
k := make([]byte, KeySize)
for i := range k {
k[i] = seed + byte(i)
}
return k
}
func mustKeyring(t *testing.T, active uint16, keys map[uint16][]byte) *Keyring {
t.Helper()
kr, err := NewKeyring(active, keys)
if err != nil {
t.Fatalf("NewKeyring: %v", err)
}
return kr
}
// TestSealOpenRoundtrip covers the happy path: Open(Seal(x)) == x, and the
// ciphertext is not the plaintext.
func TestSealOpenRoundtrip(t *testing.T) {
kr := mustKeyring(t, 1, map[uint16][]byte{1: fixedKey(0)})
for _, pt := range [][]byte{
[]byte(""),
[]byte("x"),
[]byte("a scrubbed bug report with [REDACTED_EMAIL] inside"),
bytes.Repeat([]byte("A"), 4096),
} {
sealed, err := Seal(kr, pt)
if err != nil {
t.Fatalf("Seal(%d bytes): %v", len(pt), err)
}
if len(pt) > 0 && bytes.Contains(sealed, pt) {
t.Errorf("sealed frame contains the plaintext verbatim (len %d)", len(pt))
}
got, err := Open(kr, sealed)
if err != nil {
t.Fatalf("Open: %v", err)
}
if !bytes.Equal(got, pt) {
t.Errorf("roundtrip mismatch: got %q want %q", got, pt)
}
}
}
// TestWireLayout locks the ADR #5 byte layout of a sealed frame.
func TestWireLayout(t *testing.T) {
kr := mustKeyring(t, 0xABCD, map[uint16][]byte{0xABCD: fixedKey(3)})
nonce := bytes.Repeat([]byte{0x5A}, NonceSize)
pt := []byte("payload")
frame, err := sealWithNonce(kr, pt, nonce)
if err != nil {
t.Fatalf("sealWithNonce: %v", err)
}
if got := string(frame[:4]); got != Magic {
t.Errorf("magic = %q, want %q", got, Magic)
}
if frame[4] != FormatVersion {
t.Errorf("version = 0x%02x, want 0x%02x", frame[4], FormatVersion)
}
if id := binary.BigEndian.Uint16(frame[5:7]); id != 0xABCD {
t.Errorf("key_id = 0x%04x, want 0xABCD", id)
}
if got := frame[7:19]; !bytes.Equal(got, nonce) {
t.Errorf("nonce = %x, want %x", got, nonce)
}
// header(7) + nonce(12) + ciphertext(len pt) + tag(16)
wantLen := HeaderSize + NonceSize + len(pt) + TagSize
if len(frame) != wantLen {
t.Errorf("frame len = %d, want %d", len(frame), wantLen)
}
if id, err := KeyID(frame); err != nil || id != 0xABCD {
t.Errorf("KeyID = 0x%04x, err=%v; want 0xABCD, nil", id, err)
}
}
// TestKnownAnswerVector is a byte-exact format lock. The expected frame was
// computed from Go's standard AES-256-GCM; because AES-256-GCM is deterministic
// and standardised, the Wasm SubtleCrypto provider MUST produce these same bytes
// for the same key+nonce+plaintext+AAD. If this changes, the on-disk format (and
// cross-provider compatibility) changed.
func TestKnownAnswerVector(t *testing.T) {
key := fixedKey(0) // 0x00,0x01,...,0x1f
kr := mustKeyring(t, 1, map[uint16][]byte{1: key})
nonce := make([]byte, NonceSize) // 0x00..0x0b
for i := range nonce {
nonce[i] = byte(i)
}
pt := []byte("hello world")
const wantHex = "4c4d4231010001000102030405060708090a0b2f67ba77aac5b574ff2df3f26c5bd31758566cf1bf14ae15f8fd7a"
frame, err := sealWithNonce(kr, pt, nonce)
if err != nil {
t.Fatalf("sealWithNonce: %v", err)
}
if got := hex.EncodeToString(frame); got != wantHex {
t.Errorf("known-answer frame mismatch:\n got = %s\n want = %s", got, wantHex)
}
// And it must still open.
got, err := Open(kr, frame)
if err != nil || !bytes.Equal(got, pt) {
t.Errorf("Open(known frame) = %q, %v; want %q, nil", got, err, pt)
}
}
// TestSealMatchesStdlibGCM proves the package frames *standard* AES-256-GCM: the
// sealed body equals an independent crypto/cipher GCM computation over the same
// key, nonce, plaintext, and header-as-AAD. This is the provider-independent
// contract the SubtleCrypto build also satisfies.
func TestSealMatchesStdlibGCM(t *testing.T) {
key := fixedKey(9)
const keyID = 7
kr := mustKeyring(t, keyID, map[uint16][]byte{keyID: key})
nonce := bytes.Repeat([]byte{0x11}, NonceSize)
pt := []byte("some plaintext to seal")
got, err := sealWithNonce(kr, pt, nonce)
if err != nil {
t.Fatalf("sealWithNonce: %v", err)
}
block, err := aes.NewCipher(key)
if err != nil {
t.Fatalf("aes.NewCipher: %v", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatalf("cipher.NewGCM: %v", err)
}
hdr := append([]byte(Magic), FormatVersion, 0x00, keyID)
ctTag := gcm.Seal(nil, nonce, pt, hdr)
want := append(append(append([]byte{}, hdr...), nonce...), ctTag...)
if !bytes.Equal(got, want) {
t.Errorf("framing differs from standard AES-256-GCM:\n got = %x\n want = %x", got, want)
}
}
// TestOpenWrongKeyFails: an object is not decryptable without the correct key.
func TestOpenWrongKeyFails(t *testing.T) {
sealKR := mustKeyring(t, 1, map[uint16][]byte{1: fixedKey(0)})
// Different key material, SAME key_id, so parsing succeeds and only the
// cryptographic check can reject it.
wrongKR := mustKeyring(t, 1, map[uint16][]byte{1: fixedKey(100)})
sealed, err := Seal(sealKR, []byte("secret residual PII"))
if err != nil {
t.Fatalf("Seal: %v", err)
}
if _, err := Open(wrongKR, sealed); !errors.Is(err, ErrAuth) {
t.Errorf("Open with wrong key: err = %v, want ErrAuth", err)
}
// Sanity: the right key still works.
if _, err := Open(sealKR, sealed); err != nil {
t.Errorf("Open with correct key failed: %v", err)
}
}
// TestTamperDetection: any modification to the frame makes Open fail (GCM
// authenticates ciphertext, tag, nonce, and the header via AAD).
func TestTamperDetection(t *testing.T) {
kr := mustKeyring(t, 1, map[uint16][]byte{1: fixedKey(0)})
sealed, err := Seal(kr, []byte("tamper target payload"))
if err != nil {
t.Fatalf("Seal: %v", err)
}
cases := []struct {
name string
offset int
wantIs error // nil means "any non-nil error"
}{
{"magic", 0, ErrBadMagic},
{"version", 4, ErrUnsupportedVersion},
{"key_id", 6, ErrUnknownKeyID}, // 1 -> some absent version
{"nonce", nonceOffset, ErrAuth},
{"ciphertext", bodyOffset, ErrAuth},
{"tag", len(sealed) - 1, ErrAuth},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
bad := append([]byte(nil), sealed...)
bad[tc.offset] ^= 0xFF
_, err := Open(kr, bad)
if err == nil {
t.Fatalf("tampering %s produced no error", tc.name)
}
if tc.wantIs != nil && !errors.Is(err, tc.wantIs) {
t.Errorf("tampering %s: err = %v, want %v", tc.name, err, tc.wantIs)
}
})
}
}
// TestHeaderIsAuthenticated proves the key_id in the header is bound by AAD: an
// attacker cannot relabel a frame to a different, valid key version.
func TestHeaderIsAuthenticated(t *testing.T) {
// Keyring holds two versions; seal under version 1.
kr := mustKeyring(t, 1, map[uint16][]byte{1: fixedKey(0), 2: fixedKey(50)})
sealed, err := Seal(kr, []byte("bind me"))
if err != nil {
t.Fatalf("Seal: %v", err)
}
// Rewrite the stored key_id from 1 to 2 (a version that DOES exist).
relabeled := append([]byte(nil), sealed...)
binary.BigEndian.PutUint16(relabeled[keyIDOffset:HeaderSize], 2)
if _, err := Open(kr, relabeled); !errors.Is(err, ErrAuth) {
t.Errorf("relabeled key_id: err = %v, want ErrAuth (header must be authenticated)", err)
}
}
// TestRotationRetainsOldKeys: an object sealed under key_id N still opens after
// rotation, as long as the keyring retains version N.
func TestRotationRetainsOldKeys(t *testing.T) {
key1 := fixedKey(0)
key2 := fixedKey(50)
// Before rotation: active = 1.
before := mustKeyring(t, 1, map[uint16][]byte{1: key1})
sealed, err := Seal(before, []byte("pre-rotation report"))
if err != nil {
t.Fatalf("Seal: %v", err)
}
if id, _ := KeyID(sealed); id != 1 {
t.Fatalf("sealed key_id = %d, want 1", id)
}
// After rotation: active = 2, but version 1 is RETAINED.
after := mustKeyring(t, 2, map[uint16][]byte{1: key1, 2: key2})
got, err := Open(after, sealed)
if err != nil {
t.Fatalf("Open after rotation (retained key 1): %v", err)
}
if want := []byte("pre-rotation report"); !bytes.Equal(got, want) {
t.Errorf("post-rotation decrypt = %q, want %q", got, want)
}
// New writes now use key_id 2.
sealed2, err := Seal(after, []byte("post-rotation report"))
if err != nil {
t.Fatalf("Seal (post-rotation): %v", err)
}
if id, _ := KeyID(sealed2); id != 2 {
t.Errorf("post-rotation sealed key_id = %d, want 2", id)
}
// If version 1 is RETIRED (removed), the old object is unrecoverable.
retired := mustKeyring(t, 2, map[uint16][]byte{2: key2})
if _, err := Open(retired, sealed); !errors.Is(err, ErrUnknownKeyID) {
t.Errorf("Open with retired key_id: err = %v, want ErrUnknownKeyID", err)
}
}
// TestOpenMalformed covers frame-structure rejections.
func TestOpenMalformed(t *testing.T) {
kr := mustKeyring(t, 1, map[uint16][]byte{1: fixedKey(0)})
valid, err := Seal(kr, []byte("ok"))
if err != nil {
t.Fatalf("Seal: %v", err)
}
tests := []struct {
name string
object []byte
wantIs error
}{
{"empty", nil, ErrMalformed},
{"too short", valid[:minObjectLen-1], ErrMalformed},
{"bad magic", func() []byte { b := append([]byte(nil), valid...); b[0] = 'X'; return b }(), ErrBadMagic},
{"bad version", func() []byte { b := append([]byte(nil), valid...); b[4] = 0x02; return b }(), ErrUnsupportedVersion},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := Open(kr, tc.object); !errors.Is(err, tc.wantIs) {
t.Errorf("Open(%s): err = %v, want %v", tc.name, err, tc.wantIs)
}
})
}
}
// TestParseKeyring parses the ADR #5 Secrets Store JSON shape and roundtrips.
func TestParseKeyring(t *testing.T) {
key1 := fixedKey(0)
key2 := fixedKey(200)
raw := fmt.Sprintf(`{"active":2,"keys":{"1":%q,"2":%q}}`,
base64.StdEncoding.EncodeToString(key1),
base64.StdEncoding.EncodeToString(key2))
kr, err := ParseKeyring([]byte(raw))
if err != nil {
t.Fatalf("ParseKeyring: %v", err)
}
if kr.Active() != 2 {
t.Errorf("active = %d, want 2", kr.Active())
}
// New objects use active=2; an object made with the parsed keyring opens with
// an equivalent hand-built keyring, proving the bytes decoded correctly.
sealed, err := Seal(kr, []byte("via parsed keyring"))
if err != nil {
t.Fatalf("Seal: %v", err)
}
ref := mustKeyring(t, 2, map[uint16][]byte{1: key1, 2: key2})
if got, err := Open(ref, sealed); err != nil || string(got) != "via parsed keyring" {
t.Errorf("Open via reference keyring = %q, %v", got, err)
}
}
func TestParseKeyringErrors(t *testing.T) {
shortKey := base64.StdEncoding.EncodeToString(make([]byte, 16)) // 16 bytes, not 32
fullKey := base64.StdEncoding.EncodeToString(fixedKey(0))
cases := map[string]string{
"not json": `{`,
"no keys": `{"active":1,"keys":{}}`,
"bad base64": `{"active":1,"keys":{"1":"not@@base64"}}`,
"wrong key length": fmt.Sprintf(`{"active":1,"keys":{"1":%q}}`, shortKey),
"active absent": fmt.Sprintf(`{"active":9,"keys":{"1":%q}}`, fullKey),
"bad version": fmt.Sprintf(`{"active":1,"keys":{"nope":%q}}`, fullKey),
}
for name, raw := range cases {
t.Run(name, func(t *testing.T) {
if _, err := ParseKeyring([]byte(raw)); err == nil {
t.Errorf("ParseKeyring(%s) = nil error, want failure", name)
}
})
}
}
func TestGenerateKey(t *testing.T) {
a, err := GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
if len(a) != KeySize {
t.Errorf("key length = %d, want %d", len(a), KeySize)
}
b, err := GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
if bytes.Equal(a, b) {
t.Error("two generated keys are identical; CSPRNG suspect")
}
}
func TestNewKeyringValidation(t *testing.T) {
if _, err := NewKeyring(1, nil); err == nil {
t.Error("empty keyring: want error")
}
if _, err := NewKeyring(1, map[uint16][]byte{1: make([]byte, 8)}); err == nil {
t.Error("short key: want error")
}
if _, err := NewKeyring(5, map[uint16][]byte{1: fixedKey(0)}); err == nil {
t.Error("active not present: want error")
}
// NewKeyring must copy its input (mutating the caller's slice must not change
// the keyring).
src := fixedKey(0)
kr := mustKeyring(t, 1, map[uint16][]byte{1: src})
for i := range src {
src[i] = 0xEE
}
sealed, err := Seal(kr, []byte("copy check"))
if err != nil {
t.Fatalf("Seal: %v", err)
}
if got, err := Open(kr, sealed); err != nil || string(got) != "copy check" {
t.Errorf("keyring did not copy key material: %q, %v", got, err)
}
}
// TestNoPlaintextLeak is a belt-and-braces check that a sensitive-looking
// plaintext does not appear anywhere in the sealed frame.
func TestNoPlaintextLeak(t *testing.T) {
kr := mustKeyring(t, 1, map[uint16][]byte{1: fixedKey(0)})
secret := "MARKER-plaintext-must-not-survive-encryption"
sealed, err := Seal(kr, []byte("report body: "+secret))
if err != nil {
t.Fatalf("Seal: %v", err)
}
if strings.Contains(string(sealed), secret) {
t.Error("sealed frame leaks the plaintext secret")
}
}
+284
View File
@@ -0,0 +1,284 @@
// Package crypto implements the encrypted-at-rest object format for scrubbed
// LibreMail bug-reports, exactly as fixed by ADR #5
// (docs/decisions/encryption.md): Worker-side AES-256-GCM authenticated
// encryption applied before the object is written to R2, with the key selected
// from a versioned keyring held in Cloudflare Secrets Store.
//
// # Wire format (the contract)
//
// Each object body is one self-describing binary frame:
//
// offset size field
// ------ ---- -------------------------------------------------------------
// 0 4 magic = ASCII "LMB1"
// 4 1 format_version = 0x01
// 5 2 key_id = uint16, big-endian (keyring version used)
// 7 12 nonce = 96-bit random IV (CSPRNG, unique per object)
// 19 N ciphertext = AES-256-GCM(plaintext)
// 19+N 16 auth_tag = 128-bit GCM tag (appended to the ciphertext)
//
// The 7-byte header (magic || version || key_id) is passed to GCM as the
// additional authenticated data (AAD): it is stored in the clear but is
// authenticated, so an attacker cannot flip the key_id, downgrade the format, or
// transplant a body under a different header without failing authentication.
//
// # Provider-independent by design
//
// The framing in this file carries no build constraints and is shared by both
// crypto providers. The raw AES-256-GCM primitive is the only part that differs:
//
// - gcm_host.go (build tag !(js && wasm)) uses Go's crypto/aes + crypto/cipher.
// It backs go test, cmd/devserver, and any non-Wasm build.
// - gcm_wasm.go (build tag js && wasm) uses the Workers runtime's Web Crypto
// (SubtleCrypto) via syscall/js, per the ADR's TinyGo/Wasm recommendation.
//
// AES-256-GCM is deterministic for a given key, nonce, plaintext and AAD, so both
// providers produce byte-identical frames. The wire format above — not the
// provider — is the contract, which is why an object sealed by one can be opened
// by the other.
package crypto
import (
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"strconv"
)
// Wire-format constants. These are load-bearing: they are the ADR #5 object
// layout and must not change without a format_version bump.
const (
// Magic is the 4-byte frame magic, ASCII "LMB1".
Magic = "LMB1"
// FormatVersion is the current frame format version.
FormatVersion byte = 0x01
// KeySize is the AES-256 key length in bytes.
KeySize = 32
// NonceSize is the GCM nonce/IV length in bytes (96 bits).
NonceSize = 12
// TagSize is the GCM authentication tag length in bytes (128 bits).
TagSize = 16
// HeaderSize is the length of the authenticated header (magic||version||key_id).
HeaderSize = 7
magicLen = 4
keyIDOffset = 5 // magic(4) + version(1)
nonceOffset = HeaderSize
bodyOffset = HeaderSize + NonceSize // 19: start of ciphertext||tag
// minObjectLen is the smallest possible valid frame: header + nonce + tag,
// i.e. an empty-plaintext object (ciphertext length 0).
minObjectLen = HeaderSize + NonceSize + TagSize
)
// Sentinel errors returned by Open. Callers MUST treat any of them as a hard
// failure and never fall back to "publish what we have" (ADR #5, Integrity).
var (
// ErrMalformed means the object is too short to be a valid frame.
ErrMalformed = errors.New("crypto: object too short or malformed")
// ErrBadMagic means the leading 4 bytes are not the "LMB1" magic.
ErrBadMagic = errors.New("crypto: bad magic")
// ErrUnsupportedVersion means format_version is not one this build understands.
ErrUnsupportedVersion = errors.New("crypto: unsupported format version")
// ErrUnknownKeyID means the frame's key_id is not present in the keyring.
// Never remove a key version while stored objects still reference it.
ErrUnknownKeyID = errors.New("crypto: unknown key_id")
// ErrAuth means GCM authentication failed: the ciphertext, tag, nonce, or
// authenticated header was modified, or the wrong key was supplied.
ErrAuth = errors.New("crypto: authentication failed")
)
// aead is the raw AES-256-GCM primitive seam. Exactly one implementation is
// compiled in per build target (host or Wasm); both MUST produce byte-identical
// output for identical inputs.
type aead interface {
// seal returns ciphertext||tag for plaintext under key+nonce, authenticating
// aad. key is 32 bytes, nonce is 12 bytes.
seal(key, nonce, plaintext, aad []byte) ([]byte, error)
// open returns the plaintext for ciphertextAndTag under key+nonce, verifying
// aad. A non-nil error means authentication failed.
open(key, nonce, ciphertextAndTag, aad []byte) ([]byte, error)
}
// primitive is the AES-256-GCM provider, set in the init of the build-tagged
// provider file (gcm_host.go or gcm_wasm.go).
var primitive aead
// Keyring is a parsed, in-memory versioned keyring: a set of AES-256 keys indexed
// by version (the key_id written into each frame) plus the active version used to
// encrypt new objects. Retaining superseded versions is what makes rotation
// data-loss-free (ADR #5, Key rotation).
type Keyring struct {
active uint16
keys map[uint16][]byte
}
// NewKeyring builds a Keyring from an active version and a version->key map. Each
// key must be exactly KeySize (32) bytes, and active must exist in keys. The keys
// are copied, so the caller may reuse its map.
func NewKeyring(active uint16, keys map[uint16][]byte) (*Keyring, error) {
if len(keys) == 0 {
return nil, errors.New("crypto: keyring has no keys")
}
cp := make(map[uint16][]byte, len(keys))
for id, k := range keys {
if len(k) != KeySize {
return nil, fmt.Errorf("crypto: key %d must be %d bytes, got %d", id, KeySize, len(k))
}
dup := make([]byte, KeySize)
copy(dup, k)
cp[id] = dup
}
if _, ok := cp[active]; !ok {
return nil, fmt.Errorf("crypto: active version %d not present in keyring", active)
}
return &Keyring{active: active, keys: cp}, nil
}
// Active returns the version new objects are encrypted under.
func (kr *Keyring) Active() uint16 { return kr.active }
// activeKey returns the active key and its version.
func (kr *Keyring) activeKey() ([]byte, uint16) {
return kr.keys[kr.active], kr.active
}
// key returns the key for version id, or ErrUnknownKeyID if the version has been
// retired or was never present.
func (kr *Keyring) key(id uint16) ([]byte, error) {
k, ok := kr.keys[id]
if !ok {
return nil, ErrUnknownKeyID
}
return k, nil
}
// keyringJSON is the on-the-wire shape of the Secrets Store keyring secret
// documented in ADR #5: {"active": N, "keys": {"1": "<base64>", ...}}.
type keyringJSON struct {
Active uint16 `json:"active"`
Keys map[string]string `json:"keys"`
}
// ParseKeyring decodes the JSON keyring secret (as stored in Cloudflare Secrets
// Store and read via env.BUGREPORT_ENC_KEYRING.get()) into a Keyring. Each key
// value is standard-base64 of 32 random bytes. This is provider-independent and
// host-testable so the exact same parse runs under go test and in the Worker.
func ParseKeyring(raw []byte) (*Keyring, error) {
var kj keyringJSON
if err := json.Unmarshal(raw, &kj); err != nil {
return nil, fmt.Errorf("crypto: parse keyring: %w", err)
}
if len(kj.Keys) == 0 {
return nil, errors.New("crypto: keyring has no keys")
}
keys := make(map[uint16][]byte, len(kj.Keys))
for verStr, b64 := range kj.Keys {
ver, err := strconv.ParseUint(verStr, 10, 16)
if err != nil {
return nil, fmt.Errorf("crypto: invalid key version %q: %w", verStr, err)
}
key, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("crypto: key %s is not valid base64: %w", verStr, err)
}
keys[uint16(ver)] = key
}
return NewKeyring(kj.Active, keys)
}
// GenerateKey returns a fresh 32-byte AES-256 key drawn from the CSPRNG. It is
// used by cmd/devserver (a throwaway per-run key) and by tests.
func GenerateKey() ([]byte, error) {
k := make([]byte, KeySize)
if _, err := rand.Read(k); err != nil {
return nil, fmt.Errorf("crypto: generate key: %w", err)
}
return k, nil
}
// header builds the 7-byte authenticated header for a key_id.
func header(keyID uint16) []byte {
h := make([]byte, HeaderSize)
copy(h, Magic)
h[magicLen] = FormatVersion
binary.BigEndian.PutUint16(h[keyIDOffset:HeaderSize], keyID)
return h
}
// Seal scrubbed plaintext into a complete R2 object frame using the keyring's
// active key and a fresh random nonce, per ADR #5. The returned bytes are the
// full self-describing frame and are safe to write straight to R2; only
// ciphertext ever leaves this function.
func Seal(kr *Keyring, plaintext []byte) ([]byte, error) {
nonce := make([]byte, NonceSize)
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("crypto: nonce: %w", err)
}
return sealWithNonce(kr, plaintext, nonce)
}
// sealWithNonce is Seal with a caller-supplied nonce. It exists so tests can pin
// the nonce for known-answer vectors; production code must use Seal, which draws
// a unique random nonce per object (never reuse a nonce under one key).
func sealWithNonce(kr *Keyring, plaintext, nonce []byte) ([]byte, error) {
if len(nonce) != NonceSize {
return nil, fmt.Errorf("crypto: nonce must be %d bytes, got %d", NonceSize, len(nonce))
}
key, keyID := kr.activeKey()
hdr := header(keyID)
ctTag, err := primitive.seal(key, nonce, plaintext, hdr)
if err != nil {
return nil, fmt.Errorf("crypto: seal: %w", err)
}
out := make([]byte, 0, bodyOffset+len(ctTag))
out = append(out, hdr...)
out = append(out, nonce...)
out = append(out, ctTag...)
return out, nil
}
// Open parses and decrypts an R2 object frame, selecting the key by the frame's
// key_id and verifying the authenticated header. Any tampering (to ciphertext,
// tag, nonce, or header) or a wrong/absent key yields an error; callers must
// treat that as a hard failure.
func Open(kr *Keyring, object []byte) ([]byte, error) {
if len(object) < minObjectLen {
return nil, ErrMalformed
}
if string(object[:magicLen]) != Magic {
return nil, ErrBadMagic
}
if object[magicLen] != FormatVersion {
return nil, ErrUnsupportedVersion
}
keyID := binary.BigEndian.Uint16(object[keyIDOffset:HeaderSize])
hdr := object[:HeaderSize]
nonce := object[nonceOffset:bodyOffset]
ctTag := object[bodyOffset:]
key, err := kr.key(keyID)
if err != nil {
return nil, err
}
plaintext, err := primitive.open(key, nonce, ctTag, hdr)
if err != nil {
return nil, ErrAuth
}
return plaintext, nil
}
// KeyID reads the key_id from an object frame without decrypting it. It is useful
// for ops/metrics; the value is authenticated only when Open succeeds, so do not
// trust it for anything security-sensitive on its own.
func KeyID(object []byte) (uint16, error) {
if len(object) < HeaderSize {
return 0, ErrMalformed
}
if string(object[:magicLen]) != Magic {
return 0, ErrBadMagic
}
return binary.BigEndian.Uint16(object[keyIDOffset:HeaderSize]), nil
}
+47
View File
@@ -0,0 +1,47 @@
//go:build !(js && wasm)
package crypto
// Host AES-256-GCM provider, using Go's standard crypto/aes + crypto/cipher.
//
// This backs `go test`, cmd/devserver, and every non-Wasm build. The Wasm Worker
// build uses gcm_wasm.go (SubtleCrypto) instead, because TinyGo's crypto/aes is
// unreliable (ADR #5). Both paths implement identical AES-256-GCM with a 12-byte
// nonce and 128-bit tag, so they produce byte-identical frames.
import (
"crypto/aes"
"crypto/cipher"
)
// hostAEAD implements aead with the standard library.
type hostAEAD struct{}
func init() { primitive = hostAEAD{} }
// newGCM builds an AES-256-GCM AEAD for key. cipher.NewGCM defaults to a 12-byte
// nonce and 16-byte tag, matching the wire format.
func newGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key) // 32-byte key selects AES-256
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func (hostAEAD) seal(key, nonce, plaintext, aad []byte) ([]byte, error) {
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
// Seal appends ciphertext||tag; dst nil returns a fresh slice.
return gcm.Seal(nil, nonce, plaintext, aad), nil
}
func (hostAEAD) open(key, nonce, ciphertextAndTag, aad []byte) ([]byte, error) {
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
return gcm.Open(nil, nonce, ciphertextAndTag, aad)
}
+135
View File
@@ -0,0 +1,135 @@
//go:build js && wasm
package crypto
// Wasm AES-256-GCM provider, using the Workers runtime's Web Crypto
// (SubtleCrypto) through syscall/js.
//
// Per ADR #5, TinyGo's crypto/aes is not reliable in Wasm, so the Worker build
// performs AES-256-GCM via crypto.subtle.encrypt / crypto.subtle.decrypt with
// { name: "AES-GCM", iv, additionalData, tagLength: 128 }. This produces exactly
// the same ciphertext||tag as the host crypto/cipher provider (gcm_host.go), so
// the on-disk wire format in frame.go is identical regardless of provider.
//
// This file is compiled only into the js/wasm Worker (it is excluded from host
// builds and `go test`); CI's TinyGo build is what exercises it.
import (
"fmt"
"syscall/js"
)
// subtleAEAD implements aead via SubtleCrypto.
type subtleAEAD struct{}
func init() { primitive = subtleAEAD{} }
// subtle returns the crypto.subtle object, fetched lazily to avoid any
// init-ordering assumptions about JS globals.
func subtle() js.Value {
return js.Global().Get("crypto").Get("subtle")
}
// toUint8Array copies b into a new JS Uint8Array.
func toUint8Array(b []byte) js.Value {
ua := js.Global().Get("Uint8Array").New(len(b))
if len(b) > 0 {
js.CopyBytesToJS(ua, b)
}
return ua
}
// bytesFromArrayBuffer copies an ArrayBuffer (the result of encrypt/decrypt) into
// a Go byte slice.
func bytesFromArrayBuffer(buf js.Value) []byte {
ua := js.Global().Get("Uint8Array").New(buf)
out := make([]byte, ua.Get("length").Int())
if len(out) > 0 {
js.CopyBytesToGo(out, ua)
}
return out
}
// gcmParams builds the AesGcmParams object for encrypt/decrypt.
func gcmParams(nonce, aad []byte) js.Value {
p := js.Global().Get("Object").New()
p.Set("name", "AES-GCM")
p.Set("iv", toUint8Array(nonce))
p.Set("additionalData", toUint8Array(aad))
p.Set("tagLength", 128)
return p
}
// importKey imports a raw 32-byte key as a non-extractable AES-GCM CryptoKey
// usable for both encrypt and decrypt.
func importKey(key []byte) (js.Value, error) {
algo := js.Global().Get("Object").New()
algo.Set("name", "AES-GCM")
usages := js.Global().Get("Array").New()
usages.Call("push", "encrypt")
usages.Call("push", "decrypt")
return await(subtle().Call("importKey", "raw", toUint8Array(key), algo, false, usages))
}
func (subtleAEAD) seal(key, nonce, plaintext, aad []byte) ([]byte, error) {
ck, err := importKey(key)
if err != nil {
return nil, err
}
res, err := await(subtle().Call("encrypt", gcmParams(nonce, aad), ck, toUint8Array(plaintext)))
if err != nil {
return nil, err
}
return bytesFromArrayBuffer(res), nil
}
func (subtleAEAD) open(key, nonce, ciphertextAndTag, aad []byte) ([]byte, error) {
ck, err := importKey(key)
if err != nil {
return nil, err
}
// A tampered ciphertext/tag/nonce/aad causes SubtleCrypto to reject; await
// surfaces that as an error, which Open maps to ErrAuth.
res, err := await(subtle().Call("decrypt", gcmParams(nonce, aad), ck, toUint8Array(ciphertextAndTag)))
if err != nil {
return nil, err
}
return bytesFromArrayBuffer(res), nil
}
// await resolves a JS Promise synchronously from the calling goroutine. It is
// safe because the Worker runs each request handler in its own goroutine (see
// syumai/workers), so parking here lets the JS event loop run the settling
// callback. Mirrors syumai/workers' internal AwaitPromise.
func await(p js.Value) (js.Value, error) {
resCh := make(chan js.Value, 1)
errCh := make(chan error, 1)
var then, catch js.Func
then = js.FuncOf(func(_ js.Value, args []js.Value) any {
then.Release()
catch.Release()
v := js.Undefined()
if len(args) > 0 {
v = args[0]
}
resCh <- v
return js.Undefined()
})
catch = js.FuncOf(func(_ js.Value, args []js.Value) any {
then.Release()
catch.Release()
msg := "unknown error"
if len(args) > 0 {
msg = args[0].Call("toString").String()
}
errCh <- fmt.Errorf("crypto/subtle: %s", msg)
return js.Undefined()
})
p.Call("then", then).Call("catch", catch)
select {
case v := <-resCh:
return v, nil
case err := <-errCh:
return js.Value{}, err
}
}
+6 -5
View File
@@ -27,13 +27,14 @@ const serviceName = "libremail-bug-report-ingest"
// Any other path returns 404. On the health/hello endpoints any non-GET method
// returns 405; on /v1/reports any non-POST method returns 405 (Allow: POST).
//
// The ingest endpoint is wired with a NopSink for now: it enforces the full
// HTTP contract (size cap + schema validation) but discards accepted bodies
// until the real storage sink (scrub + encrypt + R2) lands in #8/#9.
func New() http.Handler {
// sink is the storage backend for accepted reports (scrub + encrypt + R2, #9).
// It is injected so the deployed Worker supplies the real R2/Secrets-Store sink
// while cmd/devserver and tests supply an in-memory one. A nil sink defaults to
// ingest.NopSink, which enforces the full HTTP contract but discards bodies.
func New(sink ingest.Sink) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthz)
mux.Handle("/v1/reports", ingest.NewHandler(ingest.NopSink{}))
mux.Handle("/v1/reports", ingest.NewHandler(sink))
mux.HandleFunc("/", root)
return mux
}
+24 -1
View File
@@ -4,7 +4,10 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/ingest"
)
// doRequest runs a single request through the handler and returns the recorder.
@@ -12,7 +15,7 @@ func doRequest(t *testing.T, method, target string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, nil)
rec := httptest.NewRecorder()
New().ServeHTTP(rec, req)
New(nil).ServeHTTP(rec, req)
return rec
}
@@ -71,6 +74,26 @@ func TestUnknownPathReturns404(t *testing.T) {
}
}
// TestReportsRoutedToInjectedSink proves New wires the injected sink into
// POST /v1/reports (the #9 storage seam), returning 202 and storing the report.
func TestReportsRoutedToInjectedSink(t *testing.T) {
sink := &ingest.MemorySink{}
h := New(sink)
body := `{"appVersion":"1.0.0","platform":"android","report":"boom"}`
req := httptest.NewRequest(http.MethodPost, "/v1/reports", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("POST /v1/reports status = %d, want %d", rec.Code, http.StatusAccepted)
}
if sink.Len() != 1 {
t.Fatalf("injected sink stored %d reports, want 1", sink.Len())
}
}
func TestNonGetReturns405(t *testing.T) {
for _, path := range []string{"/", "/healthz"} {
rec := doRequest(t, http.MethodPost, path)
+61
View File
@@ -0,0 +1,61 @@
package storage
import (
"context"
"sort"
"sync"
)
// MemoryStore is an in-memory ObjectStore for tests, cmd/devserver, and local
// experimentation. It retains every object in a map and is not a production
// backend (it grows without bound). It is safe for concurrent use.
type MemoryStore struct {
mu sync.Mutex
objects map[string][]byte
}
// NewMemoryStore returns an empty MemoryStore.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{objects: make(map[string][]byte)}
}
// Put stores a copy of data at key.
func (m *MemoryStore) Put(_ context.Context, key string, data []byte) error {
cp := append([]byte(nil), data...)
m.mu.Lock()
defer m.mu.Unlock()
m.objects[key] = cp
return nil
}
// Get returns a copy of the object at key, or ErrNotFound.
func (m *MemoryStore) Get(_ context.Context, key string) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
data, ok := m.objects[key]
if !ok {
return nil, ErrNotFound
}
return append([]byte(nil), data...), nil
}
// Keys returns the stored object keys in sorted order (test/ops helper).
func (m *MemoryStore) Keys() []string {
m.mu.Lock()
defer m.mu.Unlock()
keys := make([]string, 0, len(m.objects))
for k := range m.objects {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// Len reports how many objects are stored.
func (m *MemoryStore) Len() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.objects)
}
var _ ObjectStore = (*MemoryStore)(nil)
+54
View File
@@ -0,0 +1,54 @@
//go:build js && wasm
package storage
// R2-backed ObjectStore for the Cloudflare Worker build. It wraps the
// syumai/workers R2 binding API; only sealed (ciphertext) frames are ever
// written, so R2 never holds plaintext or the key (ADR #5).
//
// Compiled only into the js/wasm Worker; excluded from host builds and tests.
import (
"bytes"
"context"
"io"
"github.com/syumai/workers/cloudflare/r2"
)
// R2Store is an ObjectStore backed by a Cloudflare R2 bucket binding.
type R2Store struct {
bucket *r2.Bucket
}
// NewR2Store resolves the R2 bucket bound as binding (see BucketBinding) from the
// Worker runtime context. It must be called within a request/scheduled handler,
// where the binding is available.
func NewR2Store(binding string) (*R2Store, error) {
b, err := r2.NewBucket(binding)
if err != nil {
return nil, err
}
return &R2Store{bucket: b}, nil
}
// Put writes data at key. The syumai R2 Put reads the whole body into memory and
// PUTs it as bytes.
func (s *R2Store) Put(_ context.Context, key string, data []byte) error {
_, err := s.bucket.Put(key, io.NopCloser(bytes.NewReader(data)), nil)
return err
}
// Get reads the object at key, or returns ErrNotFound when it is absent.
func (s *R2Store) Get(_ context.Context, key string) ([]byte, error) {
obj, err := s.bucket.Get(key)
if err != nil {
return nil, err
}
if obj == nil || obj.Body == nil {
return nil, ErrNotFound
}
return io.ReadAll(obj.Body)
}
var _ ObjectStore = (*R2Store)(nil)
+79
View File
@@ -0,0 +1,79 @@
package storage
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/crypto"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/ingest"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/scrub"
)
// objectKeyPrefix namespaces report objects within the bucket.
const objectKeyPrefix = "reports/"
// Sink is the real ingest.Sink. For each accepted report it:
//
// 1. scrubs the raw body (best-effort PII redaction, #8);
// 2. seals the scrubbed bytes with the keyring's active AES-256 key
// (AES-256-GCM, ADR #5) into a self-describing frame;
// 3. writes the frame to the ObjectStore under a unique, unguessable key.
//
// The store only ever receives ciphertext. Sink is provider-agnostic: paired
// with a MemoryStore it runs on the host (tests, cmd/devserver); paired with an
// R2Store it runs in the Worker. The Worker's keyring loading (from Secrets
// Store) is handled by WorkerSink, which delegates the pipeline to a Sink.
type Sink struct {
store ObjectStore
keyring *crypto.Keyring
keyFn func() string
}
// Option customises a Sink.
type Option func(*Sink)
// WithKeyFunc overrides the object-key generator. Intended for tests that need a
// deterministic key; production uses the default random key.
func WithKeyFunc(fn func() string) Option {
return func(s *Sink) { s.keyFn = fn }
}
// NewSink returns a Sink that stores into store, encrypting under kr's active
// key. kr must be non-nil.
func NewSink(store ObjectStore, kr *crypto.Keyring, opts ...Option) *Sink {
s := &Sink{store: store, keyring: kr, keyFn: defaultObjectKey}
for _, o := range opts {
o(s)
}
return s
}
// Store scrubs, encrypts, and persists one accepted report. A non-nil return
// makes the ingest endpoint answer 503 (per ADR #6), so callers should retry.
func (s *Sink) Store(ctx context.Context, raw []byte) error {
scrubbed := scrub.Scrub(raw)
sealed, err := crypto.Seal(s.keyring, scrubbed)
if err != nil {
return fmt.Errorf("storage: seal: %w", err)
}
if err := s.store.Put(ctx, s.keyFn(), sealed); err != nil {
return fmt.Errorf("storage: put: %w", err)
}
return nil
}
// defaultObjectKey builds a unique object key: a UTC timestamp (for rough
// lexicographic ordering, convenient for the weekly publish job) plus 80 bits of
// CSPRNG randomness (so keys are unguessable and collision-free within a second).
func defaultObjectKey() string {
var b [10]byte
_, _ = rand.Read(b[:])
ts := time.Now().UTC().Format("20060102T150405")
return objectKeyPrefix + ts + "-" + hex.EncodeToString(b[:])
}
// Sink satisfies the ingest storage seam.
var _ ingest.Sink = (*Sink)(nil)
+199
View File
@@ -0,0 +1,199 @@
package storage
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/crypto"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/scrub"
)
// ghToken is a GitHub-token-shaped test value, assembled at runtime so the
// contiguous token pattern never appears as a literal in source (secret scanners
// flag test tokens on sight; internal/scrub/scrub_test.go uses the same trick for
// a GitLab PAT).
var ghToken = "ghp_" + "0123456789abcdefghijklmnopqrstuvwxyz"
// rawReport is a realistic raw body carrying several kinds of PII plus some
// non-sensitive structure that must survive scrubbing.
var rawReport = `{"appVersion":"1.4.2","platform":"android",` +
`"report":"crash when syncing. contact alice@example.com. ` +
`token=` + ghToken + ` from 203.0.113.7"}`
// piiSubstrings are values that must never survive scrubbing, and must never be
// readable in the stored ciphertext.
var piiSubstrings = []string{
"alice@example.com",
ghToken,
"203.0.113.7",
}
func mustKeyring(t *testing.T) *crypto.Keyring {
t.Helper()
key, err := crypto.GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
kr, err := crypto.NewKeyring(1, map[uint16][]byte{1: key})
if err != nil {
t.Fatalf("NewKeyring: %v", err)
}
return kr
}
func TestMemoryStorePutGet(t *testing.T) {
ctx := context.Background()
m := NewMemoryStore()
if _, err := m.Get(ctx, "absent"); !errors.Is(err, ErrNotFound) {
t.Errorf("Get(absent) err = %v, want ErrNotFound", err)
}
want := []byte("some bytes")
if err := m.Put(ctx, "k", want); err != nil {
t.Fatalf("Put: %v", err)
}
got, err := m.Get(ctx, "k")
if err != nil {
t.Fatalf("Get: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("Get = %q, want %q", got, want)
}
// Stored copy must be independent of the caller's slice.
want[0] = 'X'
if again, _ := m.Get(ctx, "k"); again[0] == 'X' {
t.Error("MemoryStore did not copy the value on Put")
}
if m.Len() != 1 {
t.Errorf("Len = %d, want 1", m.Len())
}
}
// TestSinkFullPath is the end-to-end acceptance: raw report -> scrubbed ->
// encrypted -> stored, and reading it back REQUIRES the key and yields the
// scrubbed (not raw) content.
func TestSinkFullPath(t *testing.T) {
ctx := context.Background()
kr := mustKeyring(t)
store := NewMemoryStore()
sink := NewSink(store, kr)
if err := sink.Store(ctx, []byte(rawReport)); err != nil {
t.Fatalf("Store: %v", err)
}
// Exactly one object, under a reports/ key.
keys := store.Keys()
if len(keys) != 1 {
t.Fatalf("stored %d objects, want 1", len(keys))
}
if !strings.HasPrefix(keys[0], objectKeyPrefix) {
t.Errorf("object key %q lacks prefix %q", keys[0], objectKeyPrefix)
}
stored, err := store.Get(ctx, keys[0])
if err != nil {
t.Fatalf("Get stored: %v", err)
}
// The stored bytes are a sealed frame, not the raw or scrubbed plaintext.
scrubbed := scrub.Scrub([]byte(rawReport))
if bytes.Equal(stored, []byte(rawReport)) {
t.Error("stored object equals the raw body (not encrypted)")
}
if bytes.Equal(stored, scrubbed) {
t.Error("stored object equals the scrubbed plaintext (not encrypted)")
}
if string(stored[:4]) != crypto.Magic {
t.Errorf("stored object magic = %q, want %q", stored[:4], crypto.Magic)
}
// The ciphertext must not leak any PII substring in the clear.
for _, s := range piiSubstrings {
if bytes.Contains(stored, []byte(s)) {
t.Errorf("stored ciphertext leaks PII substring %q", s)
}
}
// The object is NOT decryptable without the key.
otherKR := mustKeyring(t) // different random key, same key_id 1
if _, err := crypto.Open(otherKR, stored); err == nil {
t.Error("stored object decrypted with the WRONG key; must require the correct key")
}
// With the correct key it yields exactly the scrubbed content.
opened, err := crypto.Open(kr, stored)
if err != nil {
t.Fatalf("Open with correct key: %v", err)
}
if !bytes.Equal(opened, scrubbed) {
t.Errorf("decrypted content != scrubbed content\n got = %q\n want = %q", opened, scrubbed)
}
// The decrypted (scrubbed) content has the PII removed (verify via #8's
// contract) and carries the redaction placeholders and surviving structure.
openedStr := string(opened)
for _, s := range piiSubstrings {
if strings.Contains(openedStr, s) {
t.Errorf("decrypted scrubbed content still leaks PII %q", s)
}
}
for _, p := range []string{scrub.PlaceholderEmail, scrub.PlaceholderToken, scrub.PlaceholderIP} {
if !strings.Contains(openedStr, p) {
t.Errorf("decrypted scrubbed content missing placeholder %q", p)
}
}
for _, keep := range []string{"crash when syncing", `"appVersion":"1.4.2"`} {
if !strings.Contains(openedStr, keep) {
t.Errorf("decrypted scrubbed content dropped non-PII text %q", keep)
}
}
}
// failingStore always fails Put, to exercise the Sink's error propagation (which
// drives the ingest 503 path).
type failingStore struct{}
func (failingStore) Put(context.Context, string, []byte) error {
return errors.New("backend down")
}
func (failingStore) Get(context.Context, string) ([]byte, error) {
return nil, ErrNotFound
}
func TestSinkStorePutError(t *testing.T) {
sink := NewSink(failingStore{}, mustKeyring(t))
if err := sink.Store(context.Background(), []byte(rawReport)); err == nil {
t.Error("Store returned nil despite a failing backend; want error (drives 503)")
}
}
func TestSinkUsesDistinctKeys(t *testing.T) {
ctx := context.Background()
store := NewMemoryStore()
sink := NewSink(store, mustKeyring(t))
for i := 0; i < 5; i++ {
if err := sink.Store(ctx, []byte(rawReport)); err != nil {
t.Fatalf("Store #%d: %v", i, err)
}
}
if store.Len() != 5 {
t.Errorf("stored %d objects, want 5 distinct keys", store.Len())
}
}
func TestSinkWithKeyFunc(t *testing.T) {
ctx := context.Background()
store := NewMemoryStore()
sink := NewSink(store, mustKeyring(t), WithKeyFunc(func() string { return "reports/fixed" }))
if err := sink.Store(ctx, []byte(rawReport)); err != nil {
t.Fatalf("Store: %v", err)
}
if _, err := store.Get(ctx, "reports/fixed"); err != nil {
t.Errorf("expected object at fixed key: %v", err)
}
}
+45
View File
@@ -0,0 +1,45 @@
// Package storage persists accepted LibreMail bug-reports as encrypted-at-rest
// objects, implementing the storage half of the ingest pipeline: for each
// accepted report it scrubs PII (internal/scrub, #8), encrypts the scrubbed
// bytes with AES-256-GCM (internal/crypto, ADR #5), and writes only the opaque
// ciphertext frame to the object store. The store never sees plaintext or the
// key.
//
// The package follows the repo's build-tag pattern so it is host-testable
// without TinyGo or the Workers runtime:
//
// - The ObjectStore interface, MemoryStore, and the Sink (scrub+encrypt+put)
// carry no build constraints and are unit-tested with `go test`.
// - The real R2-backed store (R2Store) and the Worker sink that loads the
// keyring from Cloudflare Secrets Store live behind //go:build js && wasm and
// are compiled by the Wasm Worker build in CI.
package storage
import (
"context"
"errors"
)
// ErrNotFound is returned by ObjectStore.Get when no object exists at the key.
var ErrNotFound = errors.New("storage: object not found")
// Binding names wired in wrangler.jsonc and provisioned by infra (#2).
const (
// BucketBinding is the R2 bucket binding name (the JS var the Worker reads).
// The bound bucket is "libremail-bug-reports" (infra defaultR2BucketName).
BucketBinding = "REPORTS_BUCKET"
// KeyringBinding is the Cloudflare Secrets Store binding holding the JSON
// keyring secret, named per ADR #5.
KeyringBinding = "BUGREPORT_ENC_KEYRING"
)
// ObjectStore is the seam for the opaque object backend. Implementations only
// ever handle already-encrypted frames.
//
// - Put writes data (a sealed frame) at key, overwriting any existing object.
// - Get reads the bytes back, or returns ErrNotFound. Get exists mainly for the
// future publish job (#35) and for tests; the ingest path is write-only.
type ObjectStore interface {
Put(ctx context.Context, key string, data []byte) error
Get(ctx context.Context, key string) ([]byte, error)
}
+133
View File
@@ -0,0 +1,133 @@
//go:build js && wasm
package storage
// WorkerSink is the production ingest.Sink for the Cloudflare Worker. It loads
// the versioned keyring from Cloudflare Secrets Store, resolves the R2 bucket
// binding, and delegates the scrub -> encrypt -> put pipeline to a plain Sink.
//
// Per ADR #5 the keyring secret is fetched inside the request handler (the
// binding's get() is async and the runtime context is only present per-request),
// not at module top-level. The parsed keyring is cached for the isolate lifetime;
// isolates recycle, which is how a rotated `active` version takes over.
//
// Compiled only into the js/wasm Worker; excluded from host builds and tests.
import (
"context"
"fmt"
"sync"
"syscall/js"
"github.com/syumai/workers/cloudflare"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/crypto"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/ingest"
)
// WorkerSink implements ingest.Sink against R2 + Secrets Store bindings.
type WorkerSink struct {
bucketBinding string
keyringBinding string
mu sync.Mutex
keyring *crypto.Keyring // cached for the isolate lifetime after first load
}
// NewWorkerSink returns a WorkerSink using the standard binding names. It does no
// I/O and touches no runtime context, so it is safe to construct at main() time;
// bindings are resolved lazily on the first Store call.
func NewWorkerSink() *WorkerSink {
return &WorkerSink{
bucketBinding: BucketBinding,
keyringBinding: KeyringBinding,
}
}
// Store loads the keyring (cached), resolves the R2 bucket, and runs the shared
// scrub+encrypt+put pipeline.
func (s *WorkerSink) Store(ctx context.Context, raw []byte) error {
kr, err := s.loadKeyring()
if err != nil {
return fmt.Errorf("storage: load keyring: %w", err)
}
store, err := NewR2Store(s.bucketBinding)
if err != nil {
return fmt.Errorf("storage: r2 binding: %w", err)
}
return NewSink(store, kr).Store(ctx, raw)
}
// loadKeyring returns the cached keyring, loading and parsing it from Secrets
// Store on first use. A failed load is not cached, so it is retried next request.
func (s *WorkerSink) loadKeyring() (*crypto.Keyring, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.keyring != nil {
return s.keyring, nil
}
raw, err := getSecret(s.keyringBinding)
if err != nil {
return nil, err
}
kr, err := crypto.ParseKeyring(raw)
if err != nil {
return nil, err
}
s.keyring = kr
return kr, nil
}
// getSecret reads a Cloudflare Secrets Store secret via `await binding.get()`.
// The returned value must never be logged or echoed (ADR #5, Key custody).
func getSecret(binding string) ([]byte, error) {
b := cloudflare.GetBinding(binding)
if b.IsUndefined() || b.IsNull() {
return nil, fmt.Errorf("secrets store binding %q is not bound", binding)
}
v, err := await(b.Call("get"))
if err != nil {
return nil, err
}
if v.IsUndefined() || v.IsNull() {
return nil, fmt.Errorf("secrets store binding %q returned no value", binding)
}
return []byte(v.String()), nil
}
// await resolves a JS Promise from the calling goroutine (the Worker runs each
// handler in its own goroutine, so the JS event loop can settle the promise).
func await(p js.Value) (js.Value, error) {
resCh := make(chan js.Value, 1)
errCh := make(chan error, 1)
var then, catch js.Func
then = js.FuncOf(func(_ js.Value, args []js.Value) any {
then.Release()
catch.Release()
v := js.Undefined()
if len(args) > 0 {
v = args[0]
}
resCh <- v
return js.Undefined()
})
catch = js.FuncOf(func(_ js.Value, args []js.Value) any {
then.Release()
catch.Release()
msg := "unknown error"
if len(args) > 0 {
msg = args[0].Call("toString").String()
}
errCh <- fmt.Errorf("secrets store: %s", msg)
return js.Undefined()
})
p.Call("then", then).Call("catch", catch)
select {
case v := <-resCh:
return v, nil
case err := <-errCh:
return js.Value{}, err
}
}
var _ ingest.Sink = (*WorkerSink)(nil)
+5 -1
View File
@@ -14,8 +14,12 @@ import (
"github.com/syumai/workers"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/handler"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/storage"
)
func main() {
workers.Serve(handler.New())
// The real storage Sink (#9): scrub -> AES-256-GCM encrypt -> R2 put, with the
// keyring loaded from Cloudflare Secrets Store on first request. Bindings
// (REPORTS_BUCKET, BUGREPORT_ENC_KEYRING) are declared in wrangler.jsonc.
workers.Serve(handler.New(storage.NewWorkerSink()))
}
+27 -1
View File
@@ -15,5 +15,31 @@
// Requires TinyGo locally; TinyGo is installed in CI.
"build": {
"command": "pnpm run build"
}
},
// R2 bucket for the encrypted-at-rest bug-report objects (ADR #5 / issue #9).
// The Worker encrypts each scrubbed report with AES-256-GCM before writing, so
// only ciphertext is ever stored here. "binding" is the JS var the Worker code
// reads (internal/storage.BucketBinding); "bucket_name" matches the bucket
// provisioned by infra/ (defaultR2BucketName = "libremail-bug-reports").
"r2_buckets": [
{
"binding": "REPORTS_BUCKET",
"bucket_name": "libremail-bug-reports"
}
],
// Cloudflare Secrets Store secret holding the versioned encryption keyring
// (ADR #5, Key custody): a single JSON secret {active, keys{ver: base64-32B}}.
// "binding" is read at runtime via env.BUGREPORT_ENC_KEYRING.get()
// (internal/storage.KeyringBinding). Replace "<store-id>" with the account's
// Secrets Store id at deploy time; it is not needed for `pnpm run build`
// (Wasm compile) or the devserver, so CI does not require it.
"secrets_store_secrets": [
{
"binding": "BUGREPORT_ENC_KEYRING",
"store_id": "<store-id>",
"secret_name": "bugreport-enc-keyring"
}
]
}