Files
LibreMail-Bug-Report-Ingest/internal/crypto/gcm_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

136 lines
4.0 KiB
Go

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