Files
LibreMail-Bug-Report-Ingest/internal/storage/sink.go
T
JMR-devandClaude Opus 4.8 bd7fe21d97 #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>
2026-07-02 15:08:21 -05:00

80 lines
2.6 KiB
Go

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)