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

134 lines
3.9 KiB
Go

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