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>
285 lines
11 KiB
Go
285 lines
11 KiB
Go
// Package crypto implements the encrypted-at-rest object format for scrubbed
|
|
// LibreMail bug-reports, exactly as fixed by ADR #5
|
|
// (docs/decisions/encryption.md): Worker-side AES-256-GCM authenticated
|
|
// encryption applied before the object is written to R2, with the key selected
|
|
// from a versioned keyring held in Cloudflare Secrets Store.
|
|
//
|
|
// # Wire format (the contract)
|
|
//
|
|
// Each object body is one self-describing binary frame:
|
|
//
|
|
// offset size field
|
|
// ------ ---- -------------------------------------------------------------
|
|
// 0 4 magic = ASCII "LMB1"
|
|
// 4 1 format_version = 0x01
|
|
// 5 2 key_id = uint16, big-endian (keyring version used)
|
|
// 7 12 nonce = 96-bit random IV (CSPRNG, unique per object)
|
|
// 19 N ciphertext = AES-256-GCM(plaintext)
|
|
// 19+N 16 auth_tag = 128-bit GCM tag (appended to the ciphertext)
|
|
//
|
|
// The 7-byte header (magic || version || key_id) is passed to GCM as the
|
|
// additional authenticated data (AAD): it is stored in the clear but is
|
|
// authenticated, so an attacker cannot flip the key_id, downgrade the format, or
|
|
// transplant a body under a different header without failing authentication.
|
|
//
|
|
// # Provider-independent by design
|
|
//
|
|
// The framing in this file carries no build constraints and is shared by both
|
|
// crypto providers. The raw AES-256-GCM primitive is the only part that differs:
|
|
//
|
|
// - gcm_host.go (build tag !(js && wasm)) uses Go's crypto/aes + crypto/cipher.
|
|
// It backs go test, cmd/devserver, and any non-Wasm build.
|
|
// - gcm_wasm.go (build tag js && wasm) uses the Workers runtime's Web Crypto
|
|
// (SubtleCrypto) via syscall/js, per the ADR's TinyGo/Wasm recommendation.
|
|
//
|
|
// AES-256-GCM is deterministic for a given key, nonce, plaintext and AAD, so both
|
|
// providers produce byte-identical frames. The wire format above — not the
|
|
// provider — is the contract, which is why an object sealed by one can be opened
|
|
// by the other.
|
|
package crypto
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
)
|
|
|
|
// Wire-format constants. These are load-bearing: they are the ADR #5 object
|
|
// layout and must not change without a format_version bump.
|
|
const (
|
|
// Magic is the 4-byte frame magic, ASCII "LMB1".
|
|
Magic = "LMB1"
|
|
// FormatVersion is the current frame format version.
|
|
FormatVersion byte = 0x01
|
|
// KeySize is the AES-256 key length in bytes.
|
|
KeySize = 32
|
|
// NonceSize is the GCM nonce/IV length in bytes (96 bits).
|
|
NonceSize = 12
|
|
// TagSize is the GCM authentication tag length in bytes (128 bits).
|
|
TagSize = 16
|
|
// HeaderSize is the length of the authenticated header (magic||version||key_id).
|
|
HeaderSize = 7
|
|
|
|
magicLen = 4
|
|
keyIDOffset = 5 // magic(4) + version(1)
|
|
nonceOffset = HeaderSize
|
|
bodyOffset = HeaderSize + NonceSize // 19: start of ciphertext||tag
|
|
// minObjectLen is the smallest possible valid frame: header + nonce + tag,
|
|
// i.e. an empty-plaintext object (ciphertext length 0).
|
|
minObjectLen = HeaderSize + NonceSize + TagSize
|
|
)
|
|
|
|
// Sentinel errors returned by Open. Callers MUST treat any of them as a hard
|
|
// failure and never fall back to "publish what we have" (ADR #5, Integrity).
|
|
var (
|
|
// ErrMalformed means the object is too short to be a valid frame.
|
|
ErrMalformed = errors.New("crypto: object too short or malformed")
|
|
// ErrBadMagic means the leading 4 bytes are not the "LMB1" magic.
|
|
ErrBadMagic = errors.New("crypto: bad magic")
|
|
// ErrUnsupportedVersion means format_version is not one this build understands.
|
|
ErrUnsupportedVersion = errors.New("crypto: unsupported format version")
|
|
// ErrUnknownKeyID means the frame's key_id is not present in the keyring.
|
|
// Never remove a key version while stored objects still reference it.
|
|
ErrUnknownKeyID = errors.New("crypto: unknown key_id")
|
|
// ErrAuth means GCM authentication failed: the ciphertext, tag, nonce, or
|
|
// authenticated header was modified, or the wrong key was supplied.
|
|
ErrAuth = errors.New("crypto: authentication failed")
|
|
)
|
|
|
|
// aead is the raw AES-256-GCM primitive seam. Exactly one implementation is
|
|
// compiled in per build target (host or Wasm); both MUST produce byte-identical
|
|
// output for identical inputs.
|
|
type aead interface {
|
|
// seal returns ciphertext||tag for plaintext under key+nonce, authenticating
|
|
// aad. key is 32 bytes, nonce is 12 bytes.
|
|
seal(key, nonce, plaintext, aad []byte) ([]byte, error)
|
|
// open returns the plaintext for ciphertextAndTag under key+nonce, verifying
|
|
// aad. A non-nil error means authentication failed.
|
|
open(key, nonce, ciphertextAndTag, aad []byte) ([]byte, error)
|
|
}
|
|
|
|
// primitive is the AES-256-GCM provider, set in the init of the build-tagged
|
|
// provider file (gcm_host.go or gcm_wasm.go).
|
|
var primitive aead
|
|
|
|
// Keyring is a parsed, in-memory versioned keyring: a set of AES-256 keys indexed
|
|
// by version (the key_id written into each frame) plus the active version used to
|
|
// encrypt new objects. Retaining superseded versions is what makes rotation
|
|
// data-loss-free (ADR #5, Key rotation).
|
|
type Keyring struct {
|
|
active uint16
|
|
keys map[uint16][]byte
|
|
}
|
|
|
|
// NewKeyring builds a Keyring from an active version and a version->key map. Each
|
|
// key must be exactly KeySize (32) bytes, and active must exist in keys. The keys
|
|
// are copied, so the caller may reuse its map.
|
|
func NewKeyring(active uint16, keys map[uint16][]byte) (*Keyring, error) {
|
|
if len(keys) == 0 {
|
|
return nil, errors.New("crypto: keyring has no keys")
|
|
}
|
|
cp := make(map[uint16][]byte, len(keys))
|
|
for id, k := range keys {
|
|
if len(k) != KeySize {
|
|
return nil, fmt.Errorf("crypto: key %d must be %d bytes, got %d", id, KeySize, len(k))
|
|
}
|
|
dup := make([]byte, KeySize)
|
|
copy(dup, k)
|
|
cp[id] = dup
|
|
}
|
|
if _, ok := cp[active]; !ok {
|
|
return nil, fmt.Errorf("crypto: active version %d not present in keyring", active)
|
|
}
|
|
return &Keyring{active: active, keys: cp}, nil
|
|
}
|
|
|
|
// Active returns the version new objects are encrypted under.
|
|
func (kr *Keyring) Active() uint16 { return kr.active }
|
|
|
|
// activeKey returns the active key and its version.
|
|
func (kr *Keyring) activeKey() ([]byte, uint16) {
|
|
return kr.keys[kr.active], kr.active
|
|
}
|
|
|
|
// key returns the key for version id, or ErrUnknownKeyID if the version has been
|
|
// retired or was never present.
|
|
func (kr *Keyring) key(id uint16) ([]byte, error) {
|
|
k, ok := kr.keys[id]
|
|
if !ok {
|
|
return nil, ErrUnknownKeyID
|
|
}
|
|
return k, nil
|
|
}
|
|
|
|
// keyringJSON is the on-the-wire shape of the Secrets Store keyring secret
|
|
// documented in ADR #5: {"active": N, "keys": {"1": "<base64>", ...}}.
|
|
type keyringJSON struct {
|
|
Active uint16 `json:"active"`
|
|
Keys map[string]string `json:"keys"`
|
|
}
|
|
|
|
// ParseKeyring decodes the JSON keyring secret (as stored in Cloudflare Secrets
|
|
// Store and read via env.BUGREPORT_ENC_KEYRING.get()) into a Keyring. Each key
|
|
// value is standard-base64 of 32 random bytes. This is provider-independent and
|
|
// host-testable so the exact same parse runs under go test and in the Worker.
|
|
func ParseKeyring(raw []byte) (*Keyring, error) {
|
|
var kj keyringJSON
|
|
if err := json.Unmarshal(raw, &kj); err != nil {
|
|
return nil, fmt.Errorf("crypto: parse keyring: %w", err)
|
|
}
|
|
if len(kj.Keys) == 0 {
|
|
return nil, errors.New("crypto: keyring has no keys")
|
|
}
|
|
keys := make(map[uint16][]byte, len(kj.Keys))
|
|
for verStr, b64 := range kj.Keys {
|
|
ver, err := strconv.ParseUint(verStr, 10, 16)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("crypto: invalid key version %q: %w", verStr, err)
|
|
}
|
|
key, err := base64.StdEncoding.DecodeString(b64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("crypto: key %s is not valid base64: %w", verStr, err)
|
|
}
|
|
keys[uint16(ver)] = key
|
|
}
|
|
return NewKeyring(kj.Active, keys)
|
|
}
|
|
|
|
// GenerateKey returns a fresh 32-byte AES-256 key drawn from the CSPRNG. It is
|
|
// used by cmd/devserver (a throwaway per-run key) and by tests.
|
|
func GenerateKey() ([]byte, error) {
|
|
k := make([]byte, KeySize)
|
|
if _, err := rand.Read(k); err != nil {
|
|
return nil, fmt.Errorf("crypto: generate key: %w", err)
|
|
}
|
|
return k, nil
|
|
}
|
|
|
|
// header builds the 7-byte authenticated header for a key_id.
|
|
func header(keyID uint16) []byte {
|
|
h := make([]byte, HeaderSize)
|
|
copy(h, Magic)
|
|
h[magicLen] = FormatVersion
|
|
binary.BigEndian.PutUint16(h[keyIDOffset:HeaderSize], keyID)
|
|
return h
|
|
}
|
|
|
|
// Seal scrubbed plaintext into a complete R2 object frame using the keyring's
|
|
// active key and a fresh random nonce, per ADR #5. The returned bytes are the
|
|
// full self-describing frame and are safe to write straight to R2; only
|
|
// ciphertext ever leaves this function.
|
|
func Seal(kr *Keyring, plaintext []byte) ([]byte, error) {
|
|
nonce := make([]byte, NonceSize)
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return nil, fmt.Errorf("crypto: nonce: %w", err)
|
|
}
|
|
return sealWithNonce(kr, plaintext, nonce)
|
|
}
|
|
|
|
// sealWithNonce is Seal with a caller-supplied nonce. It exists so tests can pin
|
|
// the nonce for known-answer vectors; production code must use Seal, which draws
|
|
// a unique random nonce per object (never reuse a nonce under one key).
|
|
func sealWithNonce(kr *Keyring, plaintext, nonce []byte) ([]byte, error) {
|
|
if len(nonce) != NonceSize {
|
|
return nil, fmt.Errorf("crypto: nonce must be %d bytes, got %d", NonceSize, len(nonce))
|
|
}
|
|
key, keyID := kr.activeKey()
|
|
hdr := header(keyID)
|
|
ctTag, err := primitive.seal(key, nonce, plaintext, hdr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("crypto: seal: %w", err)
|
|
}
|
|
out := make([]byte, 0, bodyOffset+len(ctTag))
|
|
out = append(out, hdr...)
|
|
out = append(out, nonce...)
|
|
out = append(out, ctTag...)
|
|
return out, nil
|
|
}
|
|
|
|
// Open parses and decrypts an R2 object frame, selecting the key by the frame's
|
|
// key_id and verifying the authenticated header. Any tampering (to ciphertext,
|
|
// tag, nonce, or header) or a wrong/absent key yields an error; callers must
|
|
// treat that as a hard failure.
|
|
func Open(kr *Keyring, object []byte) ([]byte, error) {
|
|
if len(object) < minObjectLen {
|
|
return nil, ErrMalformed
|
|
}
|
|
if string(object[:magicLen]) != Magic {
|
|
return nil, ErrBadMagic
|
|
}
|
|
if object[magicLen] != FormatVersion {
|
|
return nil, ErrUnsupportedVersion
|
|
}
|
|
keyID := binary.BigEndian.Uint16(object[keyIDOffset:HeaderSize])
|
|
hdr := object[:HeaderSize]
|
|
nonce := object[nonceOffset:bodyOffset]
|
|
ctTag := object[bodyOffset:]
|
|
|
|
key, err := kr.key(keyID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plaintext, err := primitive.open(key, nonce, ctTag, hdr)
|
|
if err != nil {
|
|
return nil, ErrAuth
|
|
}
|
|
return plaintext, nil
|
|
}
|
|
|
|
// KeyID reads the key_id from an object frame without decrypting it. It is useful
|
|
// for ops/metrics; the value is authenticated only when Open succeeds, so do not
|
|
// trust it for anything security-sensitive on its own.
|
|
func KeyID(object []byte) (uint16, error) {
|
|
if len(object) < HeaderSize {
|
|
return 0, ErrMalformed
|
|
}
|
|
if string(object[:magicLen]) != Magic {
|
|
return 0, ErrBadMagic
|
|
}
|
|
return binary.BigEndian.Uint16(object[keyIDOffset:HeaderSize]), nil
|
|
}
|