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>
48 lines
1.3 KiB
Go
48 lines
1.3 KiB
Go
//go:build !(js && wasm)
|
|
|
|
package crypto
|
|
|
|
// Host AES-256-GCM provider, using Go's standard crypto/aes + crypto/cipher.
|
|
//
|
|
// This backs `go test`, cmd/devserver, and every non-Wasm build. The Wasm Worker
|
|
// build uses gcm_wasm.go (SubtleCrypto) instead, because TinyGo's crypto/aes is
|
|
// unreliable (ADR #5). Both paths implement identical AES-256-GCM with a 12-byte
|
|
// nonce and 128-bit tag, so they produce byte-identical frames.
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
)
|
|
|
|
// hostAEAD implements aead with the standard library.
|
|
type hostAEAD struct{}
|
|
|
|
func init() { primitive = hostAEAD{} }
|
|
|
|
// newGCM builds an AES-256-GCM AEAD for key. cipher.NewGCM defaults to a 12-byte
|
|
// nonce and 16-byte tag, matching the wire format.
|
|
func newGCM(key []byte) (cipher.AEAD, error) {
|
|
block, err := aes.NewCipher(key) // 32-byte key selects AES-256
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cipher.NewGCM(block)
|
|
}
|
|
|
|
func (hostAEAD) seal(key, nonce, plaintext, aad []byte) ([]byte, error) {
|
|
gcm, err := newGCM(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Seal appends ciphertext||tag; dst nil returns a fresh slice.
|
|
return gcm.Seal(nil, nonce, plaintext, aad), nil
|
|
}
|
|
|
|
func (hostAEAD) open(key, nonce, ciphertextAndTag, aad []byte) ([]byte, error) {
|
|
gcm, err := newGCM(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return gcm.Open(nil, nonce, ciphertextAndTag, aad)
|
|
}
|