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>
136 lines
4.0 KiB
Go
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
|
|
}
|
|
}
|