Files
LibreMail-Bug-Report-Ingest/internal/storage/storage_test.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

200 lines
6.0 KiB
Go

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)
}
}