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>
80 lines
2.6 KiB
Go
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)
|