diff --git a/go.mod b/go.mod index e64016ac..014c02dc 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,6 @@ require ( github.com/fil-forge/swarf v0.0.1-0.20260821055917-4921bc49627c github.com/fil-forge/ucantone v0.0.0-20260817170631-3a20cd59fabc github.com/fil-forge/versitygw v0.0.0-20260716095011-7a65883d595a - github.com/filecoin-project/go-fee v0.1.1-0.20260818173301-72a3a35f83ef github.com/gofiber/fiber/v3 v3.5.0 github.com/google/uuid v1.6.0 github.com/ipfs/go-block-format v0.2.4 @@ -27,6 +26,7 @@ require ( github.com/multiformats/go-multicodec v0.10.0 github.com/multiformats/go-multihash v0.2.3 github.com/oklog/ulid/v2 v2.1.2 + github.com/openbao/openbao/api/v2 v2.6.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pressly/goose/v3 v3.27.2 github.com/spf13/cobra v1.10.2 @@ -37,7 +37,6 @@ require ( go.uber.org/fx v1.24.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.28.0 - golang.org/x/sys v0.47.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da ) @@ -119,8 +118,11 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.7 // indirect github.com/hashicorp/go-version v1.9.0 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/hashicorp/vault-client-go v0.4.3 // indirect github.com/in-toto/attestation v1.2.0 // indirect github.com/in-toto/in-toto-golang v0.11.0 // indirect @@ -148,6 +150,7 @@ require ( github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/buildkit v0.31.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect @@ -243,6 +246,7 @@ require ( golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect diff --git a/go.sum b/go.sum index 14043814..d52a1436 100644 --- a/go.sum +++ b/go.sum @@ -169,8 +169,8 @@ github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/ github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fil-forge/automobile v0.0.1 h1:9xB3yc4l5b9EdRJSJcNwudgBFNHoMPEAdcb7GfobLhA= @@ -193,8 +193,6 @@ github.com/fil-forge/versitygw v0.0.0-20260716095011-7a65883d595a h1:lDwnNmF4LNb github.com/fil-forge/versitygw v0.0.0-20260716095011-7a65883d595a/go.mod h1:t73Wa2xqpT0NdY+SzRZDiO+tQD13b3UEZRoe2wYFiIE= github.com/filecoin-project/go-data-segment v0.0.1 h1:1wmDxOG4ubWQm3ZC1XI5nCon5qgSq7Ra3Rb6Dbu10Gs= github.com/filecoin-project/go-data-segment v0.0.1/go.mod h1:H0/NKbsRxmRFBcLibmABv+yFNHdmtl5AyplYLnb0Zv4= -github.com/filecoin-project/go-fee v0.1.1-0.20260818173301-72a3a35f83ef h1:o45NsZZct1bbwdfBKFZyWiHa7jlK3nGR4pNmr1ioBys= -github.com/filecoin-project/go-fee v0.1.1-0.20260818173301-72a3a35f83ef/go.mod h1:A+3InT4C3mn8B10ZQ+ySX7hxjYxN9MlsoF/87RuIUvg= github.com/filecoin-project/go-fil-commcid v0.3.1 h1:4EfxpHSlvtkOqa9weG2Yt5kxFmPib2xU7Uc9Lbqk7fs= github.com/filecoin-project/go-fil-commcid v0.3.1/go.mod h1:z7Ssf8d7kspF9QRAVHDbZ+43JK4mkhbGH5lyph1TnKY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -265,6 +263,8 @@ github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= github.com/go-openapi/validate v0.25.3 h1:4nzAIavcJ7WveHK2+V1UAkZK3kWcjzxZCzjfZAfavKs= github.com/go-openapi/validate v0.25.3/go.mod h1:GemfuGMyYpIaBoKpX3z8sLywrmxpzWVOoJ7R0VeAVuk= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= @@ -313,12 +313,18 @@ github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVU github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/vault-client-go v0.4.3 h1:zG7STGVgn/VK6rnZc0k8PGbfv2x/sJExRKHSUg3ljWc= github.com/hashicorp/vault-client-go v0.4.3/go.mod h1:4tDw7Uhq5XOxS1fO+oMtotHL7j4sB9cp0T7U6m4FzDY= github.com/in-toto/attestation v1.2.0 h1:aPRUZ3azbqD7yEBD5fP3TD8Dszf+YHo284SOcpahjQk= @@ -411,6 +417,8 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/buildkit v0.31.0 h1:hMUAbQGgjtzJDDOZ6o7MQk5XBZkBTyzLWEvnjguHHQI= github.com/moby/buildkit v0.31.0/go.mod h1:YM5iNEbNCc6L1Zt3YWFB/aXNLufvf4Rcu0DPlc9HwQg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -475,6 +483,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/openbao/openbao/api/v2 v2.6.0 h1:KvfspAaL9bab9hI8jFYkV2cgtSrwWtaG+k9AUTHWU4M= +github.com/openbao/openbao/api/v2 v2.6.0/go.mod h1:H4IWiH+2rgF/TbrsUbsfrMyGoqojkLqxPCRLENSMnSo= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= diff --git a/regionkey/inprocess.go b/regionkey/inprocess.go new file mode 100644 index 00000000..2bbffe6e --- /dev/null +++ b/regionkey/inprocess.go @@ -0,0 +1,163 @@ +package regionkey + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "sync" +) + +// KEKLen is the required length in bytes of a region KEK: 32 bytes (AES-256). +const KEKLen = 32 + +// gcmNonceLen is the standard 12-byte AES-GCM nonce length. A wrap generates +// a fresh random nonce and carries it as the first bytes of the wrapped +// ciphertext. +const gcmNonceLen = 12 + +// InProcessProvider is the in-process region [Provider] for tests and +// development: AES-256-GCM under a per-binding subkey, mirroring the +// semantics of the production secrets-manager wrap (OpenBao transit +// aes256-gcm96 with derived=true, context bound to (space, blob digest)) so +// code exercised against it sees the same properties — most importantly that +// a wrap authenticates only against its own binding. It holds raw KEK bytes in +// ordinary process memory, which a production provider never does: per the +// regional security RFC, real key custody belongs to the secrets manager, +// which implements [Provider] directly and never exports a key. That is also +// why there is no key-custody seam here — this provider is the only +// implementation that would ever consume one. +// +// The provider keeps every key version it has ever held (archive-don't- +// destroy): [InProcessProvider.Rotate] makes a new version current for new +// wraps while existing wraps keep unwrapping under theirs — the encryption +// RFC's rotation-readiness groundwork. Every binding resolves to the single +// current key; the region-key cardinality decision (FIL-572) is deferred. +type InProcessProvider struct { + mu sync.RWMutex + current KeyVersion + keys map[KeyVersion][]byte +} + +var _ Provider = (*InProcessProvider)(nil) + +// NewInProcessProvider returns a provider holding kek as the current region +// key under version. kek must be exactly [KEKLen] bytes and version must be +// non-empty. kek is copied; the caller's slice is not retained. +func NewInProcessProvider(version KeyVersion, kek []byte) (*InProcessProvider, error) { + if err := validateKey(version, kek); err != nil { + return nil, err + } + return &InProcessProvider{ + current: version, + keys: map[KeyVersion][]byte{version: bytes.Clone(kek)}, + }, nil +} + +// Rotate makes kek, under version, the current key for new wraps and archives +// the previous version so existing wraps still unwrap — the archive-don't- +// destroy rotation the RFC's groundwork calls for. It rejects a version the +// provider already holds, and the same invalid inputs the constructor does. +func (p *InProcessProvider) Rotate(version KeyVersion, kek []byte) error { + if err := validateKey(version, kek); err != nil { + return err + } + p.mu.Lock() + defer p.mu.Unlock() + if _, ok := p.keys[version]; ok { + return fmt.Errorf("regionkey: key version %q already exists", version) + } + p.keys[version] = bytes.Clone(kek) + p.current = version + return nil +} + +// validateKey enforces the constructor/Rotate input contract: a non-empty +// version naming a KEKLen-byte key. +func validateKey(version KeyVersion, kek []byte) error { + if version == "" { + return errors.New("regionkey: key version must not be empty") + } + if len(kek) != KEKLen { + return fmt.Errorf("regionkey: region KEK must be %d bytes (AES-256), got %d", KEKLen, len(kek)) + } + return nil +} + +// Wrap implements [Provider.Wrap]: it seals cek with AES-256-GCM under the +// binding-derived subkey of the current region KEK, prepends the fresh random +// nonce, and tags the result with the KEK's version. +func (p *InProcessProvider) Wrap(_ context.Context, binding BindingContext, cek []byte) (WrappedKey, error) { + p.mu.RLock() + version := p.current + kek := p.keys[version] + p.mu.RUnlock() + + aead, err := boundAEAD(kek, binding) + if err != nil { + return WrappedKey{}, err + } + nonce := make([]byte, gcmNonceLen) + if _, err := rand.Read(nonce); err != nil { + return WrappedKey{}, fmt.Errorf("regionkey: generating nonce: %w", err) + } + return WrappedKey{Version: version, Ciphertext: aead.Seal(nonce, nonce, cek, nil)}, nil +} + +// Unwrap implements [Provider.Unwrap]: it derives the same binding-bound +// subkey from the KEK named by wrapped.Version and opens the ciphertext. A +// wrong key, a wrong binding, and tampered bytes all fail authentication and +// surface as an error wrapping [ErrAuthentication]; a version the provider +// does not hold surfaces as [ErrUnknownVersion]. +func (p *InProcessProvider) Unwrap(_ context.Context, binding BindingContext, wrapped WrappedKey) ([]byte, error) { + p.mu.RLock() + kek, ok := p.keys[wrapped.Version] + p.mu.RUnlock() + if !ok { + return nil, fmt.Errorf("regionkey: fetching KEK version %q: %w", wrapped.Version, ErrUnknownVersion) + } + + aead, err := boundAEAD(kek, binding) + if err != nil { + return nil, err + } + if len(wrapped.Ciphertext) < gcmNonceLen { + return nil, fmt.Errorf("regionkey: wrapped CEK shorter than a nonce: %w", ErrAuthentication) + } + nonce, sealed := wrapped.Ciphertext[:gcmNonceLen], wrapped.Ciphertext[gcmNonceLen:] + cek, err := aead.Open(nil, nonce, sealed, nil) + if err != nil { + return nil, fmt.Errorf("regionkey: unwrapping CEK: %w", ErrAuthentication) + } + return cek, nil +} + +// boundAEAD builds the AES-256-GCM AEAD for one (KEK, binding) pair. The +// subkey is HKDF-SHA256(kek, info=binding), so every binding gets its own GCM +// key — the software analogue of OpenBao transit's derived=true context +// binding, and what makes a wrong-binding unwrap an authentication failure. +// The KEK length check restates the constructor/Rotate invariant as a cheap +// guard. +func boundAEAD(kek []byte, binding BindingContext) (cipher.AEAD, error) { + if len(kek) != KEKLen { + return nil, fmt.Errorf("regionkey: KEK is %d bytes, want %d", len(kek), KEKLen) + } + subkey, err := hkdf.Key(sha256.New, kek, nil, string(bindingBytes(binding)), KEKLen) + if err != nil { + return nil, fmt.Errorf("regionkey: deriving binding subkey: %w", err) + } + block, err := aes.NewCipher(subkey) + if err != nil { + return nil, fmt.Errorf("regionkey: building cipher: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("regionkey: building GCM: %w", err) + } + return aead, nil +} diff --git a/regionkey/kek.go b/regionkey/kek.go deleted file mode 100644 index 8ea19ae1..00000000 --- a/regionkey/kek.go +++ /dev/null @@ -1,111 +0,0 @@ -package regionkey - -import ( - "fmt" - "os" - "unsafe" -) - -// KEK holds raw region key-encryption-key bytes in a locked, non-swappable -// memory buffer. It is the in-process home of a KEK for the brief window a -// [SoftwareProvider] needs it: the backing memory is pinned with mlock (or the -// platform equivalent) so it cannot be paged to disk, and [KEK.Destroy] zeroes -// and unlocks it. -// -// Ownership of a KEK passes to whoever receives it from a [KEKSource]. That -// owner MUST Destroy it as soon as its single wrap or unwrap completes, and -// should arrange the Destroy with a defer immediately after obtaining the KEK -// so the bytes are wiped on every path, including panics and errors. A KEK is -// not safe for concurrent use. -// -// mlock acts at page granularity: locking a slice locks the whole page(s) it -// spans, and unlocking it unlocks them. If two KEKs shared a page, destroying -// one would unlock the other. A KEK therefore takes sole ownership of the -// page(s) it locks by over-allocating and locking only a page-aligned window -// that lies entirely within its own allocation — so no other Go object can sit -// on a locked page, and Destroy's unlock touches nothing but this KEK. -type KEK struct { - // backing is the raw over-allocated slice; the KEK retains it so the locked - // window stays alive (Go's GC does not move heap allocations) and to keep - // the page exclusively its own. - backing []byte - // page is the page-aligned, page-sized window within backing that is - // mlocked; it is what Destroy zeroes and unlocks. - page []byte - // key is page[:n], the n bytes that hold the KEK itself. - key []byte - destroyed bool -} - -// NewKEK returns a KEK holding a copy of key in freshly locked memory. It is -// how a [KEKSource] implementation outside this package (for example a -// Vault-backed source) hands a fetched key to a [Provider]: read the raw bytes, -// copy them in with NewKEK, then zero the caller's own copy. The key slice is -// neither retained nor modified. -// -// It returns an error if the memory cannot be locked; see newKEK. -func NewKEK(key []byte) (*KEK, error) { - k, err := newKEK(len(key)) - if err != nil { - return nil, err - } - copy(k.key, key) - return k, nil -} - -// newKEK allocates a KEK of size bytes whose backing memory is locked. It -// returns an error if the memory cannot be locked (see mlock_unix.go and -// mlock_other.go); callers treat that as fatal rather than proceeding with an -// unlocked key, so a misconfigured host fails closed instead of silently -// weakening the exposure guarantee. -func newKEK(size int) (*KEK, error) { - if size <= 0 { - return nil, fmt.Errorf("regionkey: KEK size must be positive, got %d", size) - } - - pageSize := os.Getpagesize() - pages := (size + pageSize - 1) / pageSize - lockLen := pages * pageSize - - // Over-allocate by a page so a full lockLen-byte, page-aligned window is - // guaranteed to fit inside this single allocation, giving the KEK exclusive - // ownership of every page it locks. - backing := make([]byte, lockLen+pageSize) - addr := uintptr(unsafe.Pointer(&backing[0])) - off := int((uintptr(pageSize) - addr%uintptr(pageSize)) % uintptr(pageSize)) - page := backing[off : off+lockLen] - - if err := lock(page); err != nil { - zero(backing) - return nil, fmt.Errorf("regionkey: locking KEK memory: %w", err) - } - return &KEK{backing: backing, page: page, key: page[:size]}, nil -} - -// Bytes returns the raw KEK bytes for use in a single operation. The slice -// aliases the locked buffer and is valid only until [KEK.Destroy]; callers must -// not retain it past the operation. After Destroy the slice is all zeroes. -func (k *KEK) Bytes() []byte { - return k.key -} - -// Destroy zeroes the KEK bytes and unlocks the backing memory. It is idempotent -// and safe to defer. After Destroy the KEK must not be used to wrap or unwrap. -func (k *KEK) Destroy() { - if k.destroyed { - return - } - k.destroyed = true - // Zero the whole locked page (the key plus any slack) while it is still - // guaranteed resident, then unlock. Any unlock error is unrecoverable here - // and the bytes are already zeroed, so it is deliberately ignored. - zero(k.page) - _ = unlock(k.page) -} - -// zero overwrites b with zeroes, a best-effort wipe of key material. -func zero(b []byte) { - for i := range b { - b[i] = 0 - } -} diff --git a/regionkey/kek_internal_test.go b/regionkey/kek_internal_test.go deleted file mode 100644 index e480bdf2..00000000 --- a/regionkey/kek_internal_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package regionkey - -import ( - "bytes" - "os" - "testing" - "unsafe" - - "github.com/stretchr/testify/require" -) - -// TestKEKDestroyZeroesAndIsIdempotent checks the core hygiene contract of the -// locked buffer: Destroy wipes the bytes, and a second Destroy is a harmless -// no-op. It is an internal test because it inspects the buffer directly. -func TestKEKDestroyZeroesAndIsIdempotent(t *testing.T) { - k, err := newKEK(KEKLen) - require.NoError(t, err, "newKEK") - - for i := range k.page { - k.page[i] = 0xAB - } - require.NotEqual(t, make([]byte, KEKLen), k.Bytes(), "key should be non-zero before Destroy") - - k.Destroy() - require.Equal(t, make([]byte, len(k.page)), k.page, "Destroy must zero the whole locked page") - require.Equal(t, make([]byte, KEKLen), k.Bytes(), "Destroy must zero the key") - - // Idempotent: a second Destroy neither panics nor changes anything. - require.NotPanics(t, k.Destroy) - require.Equal(t, make([]byte, KEKLen), k.Bytes()) -} - -// TestNewKEKCopiesAndLocks verifies NewKEK copies the caller's key into an -// independent locked buffer, so zeroing the caller's slice does not disturb the -// KEK. -func TestNewKEKCopiesAndLocks(t *testing.T) { - key := bytes.Repeat([]byte{0xCD}, KEKLen) - k, err := NewKEK(key) - require.NoError(t, err, "NewKEK") - defer k.Destroy() - - require.Equal(t, key, k.Bytes(), "NewKEK must copy the key bytes") - - // Zeroing the caller's slice must not affect the KEK's own copy. - zero(key) - require.Equal(t, bytes.Repeat([]byte{0xCD}, KEKLen), k.Bytes(), "KEK must hold an independent copy") -} - -// TestKEKLocksAnExclusivePage checks the anti-page-sharing invariant: the -// locked window is page-aligned, a whole number of pages long, and lies -// entirely within the KEK's own backing allocation, so no other object can -// occupy a page this KEK locks and Destroy's unlock affects nothing else. -func TestKEKLocksAnExclusivePage(t *testing.T) { - pageSize := os.Getpagesize() - k, err := newKEK(KEKLen) - require.NoError(t, err, "newKEK") - defer k.Destroy() - - pageAddr := uintptr(unsafe.Pointer(&k.page[0])) - require.Zero(t, int(pageAddr)%pageSize, "locked window must start on a page boundary") - require.Equal(t, pageSize, len(k.page), "a KEKLen key locks exactly one page") - - backingStart := uintptr(unsafe.Pointer(&k.backing[0])) - require.GreaterOrEqual(t, pageAddr, backingStart, "locked window must sit inside backing") - require.LessOrEqual(t, pageAddr+uintptr(len(k.page)), backingStart+uintptr(len(k.backing)), - "locked window must end inside backing") -} - -func TestNewKEKRejectsNonPositiveSize(t *testing.T) { - _, err := newKEK(0) - require.Error(t, err) - _, err = newKEK(-1) - require.Error(t, err) -} diff --git a/regionkey/mlock_other.go b/regionkey/mlock_other.go deleted file mode 100644 index 93ed036f..00000000 --- a/regionkey/mlock_other.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build !unix - -package regionkey - -import "errors" - -// errLockUnsupported reports that this platform has no supported way to lock -// memory against swapping. The region provider fails closed on it: newKEK -// returns an error rather than holding a raw KEK in swappable memory, so the -// exposure-hygiene guarantee is never silently dropped. Region custody is a -// server-side concern and the supported deployment targets (Linux, macOS) are -// all unix; this fallback exists so the package still builds elsewhere. -var errLockUnsupported = errors.New("regionkey: locking memory is not supported on this platform") - -func lock(b []byte) error { - if len(b) == 0 { - return nil - } - return errLockUnsupported -} - -func unlock(b []byte) error { - return nil -} diff --git a/regionkey/mlock_unix.go b/regionkey/mlock_unix.go deleted file mode 100644 index 011ae546..00000000 --- a/regionkey/mlock_unix.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build unix - -package regionkey - -import "golang.org/x/sys/unix" - -// lock pins b's backing pages in RAM with mlock(2) so the KEK they hold cannot -// be paged to swap. mlock operates at page granularity, so this locks the whole -// page(s) the slice spans; over-locking neighbouring bytes is harmless for the -// no-swap guarantee. Go's garbage collector does not move heap allocations, so -// the pinned pages stay valid for the buffer's lifetime. -// -// mlock fails if the process would exceed RLIMIT_MEMLOCK; a host running the -// region provider must raise that limit (for a container, grant CAP_IPC_LOCK or -// set --ulimit memlock). newKEK surfaces the failure rather than proceeding -// with an unlocked key. -func lock(b []byte) error { - if len(b) == 0 { - return nil - } - return unix.Mlock(b) -} - -// unlock reverses lock with munlock(2), allowing the pages to be paged again. -func unlock(b []byte) error { - if len(b) == 0 { - return nil - } - return unix.Munlock(b) -} diff --git a/regionkey/openbao.go b/regionkey/openbao.go new file mode 100644 index 00000000..6734198a --- /dev/null +++ b/regionkey/openbao.go @@ -0,0 +1,140 @@ +package regionkey + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/openbao/openbao/api/v2" +) + +// OpenBaoProvider is the production region [Provider] of the regional +// security RFC: the wrap is an AES-256-GCM transit operation performed inside +// the region's OpenBao, against a key created with type aes256-gcm96 and +// derived=true, so the region KEK is non-exportable and never enters Ingot's +// process. The binding context rides transit's derivation context: each +// (space, blob digest) gets its own derived subkey, and wrap material +// transplanted between rows fails authentication — the same semantics +// [InProcessProvider] mirrors for tests. +// +// The provider owns only the encrypt/decrypt calls. Everything else about the +// key is deployment and operator business: creating it (derived=true, +// exportable left false), rotating it, advancing min_decryption_version, and +// the batched rewrap campaigns that move stored ciphertexts between versions +// — Ingot's token needs encrypt, decrypt, and rewrap only. +type OpenBaoProvider struct { + logical *api.Logical + mount string + key string +} + +var _ Provider = (*OpenBaoProvider)(nil) + +// DefaultTransitMount is the transit engine's conventional mount path, used +// when NewOpenBaoProvider is given an empty mount. +const DefaultTransitMount = "transit" + +// NewOpenBaoProvider returns a provider wrapping CEKs with the transit key +// named key on the given mount (empty mount means [DefaultTransitMount]). +// The caller wires the client — address (unix socket or TCP), token, TLS — +// so custody of the connection stays with the deployment. +func NewOpenBaoProvider(client *api.Client, mount, key string) (*OpenBaoProvider, error) { + if client == nil { + return nil, errors.New("regionkey: OpenBao client must not be nil") + } + if key == "" { + return nil, errors.New("regionkey: transit key name must not be empty") + } + if mount == "" { + mount = DefaultTransitMount + } + return &OpenBaoProvider{logical: client.Logical(), mount: mount, key: key}, nil +} + +// Wrap implements [Provider.Wrap]: a transit encrypt of cek under the key's +// current version, with the binding as the derivation context. The returned +// ciphertext is transit's "vault:vN:…" string, which embeds the version it +// was wrapped under; WrappedKey.Version records the same version ("vN") for +// the provider-agnostic bookkeeping column. +func (p *OpenBaoProvider) Wrap(ctx context.Context, binding BindingContext, cek []byte) (WrappedKey, error) { + secret, err := p.logical.WriteWithContext(ctx, p.mount+"/encrypt/"+p.key, map[string]interface{}{ + "plaintext": base64.StdEncoding.EncodeToString(cek), + "context": base64.StdEncoding.EncodeToString(bindingBytes(binding)), + }) + if err != nil { + return WrappedKey{}, fmt.Errorf("regionkey: transit encrypt: %w", err) + } + ciphertext, ok := secret.Data["ciphertext"].(string) + if !ok || ciphertext == "" { + return WrappedKey{}, errors.New("regionkey: transit encrypt returned no ciphertext") + } + version, err := keyVersionOf(secret.Data["key_version"]) + if err != nil { + return WrappedKey{}, fmt.Errorf("regionkey: transit encrypt: %w", err) + } + return WrappedKey{Version: version, Ciphertext: []byte(ciphertext)}, nil +} + +// Unwrap implements [Provider.Unwrap]: a transit decrypt with the binding as +// the derivation context. Transit selects the KEK version from the +// ciphertext's own "vault:vN:" prefix, so wrapped.Version is not sent; it is +// the DB row's bookkeeping copy of the same fact. A wrong binding and +// tampered bytes fail authentication inside OpenBao and surface as +// [ErrAuthentication]; a version the key no longer serves (behind +// min_decryption_version, or from some other key entirely) surfaces as +// [ErrUnknownVersion]. +func (p *OpenBaoProvider) Unwrap(ctx context.Context, binding BindingContext, wrapped WrappedKey) ([]byte, error) { + secret, err := p.logical.WriteWithContext(ctx, p.mount+"/decrypt/"+p.key, map[string]interface{}{ + "ciphertext": string(wrapped.Ciphertext), + "context": base64.StdEncoding.EncodeToString(bindingBytes(binding)), + }) + if err != nil { + return nil, fmt.Errorf("regionkey: transit decrypt: %w", mapTransitDecryptError(err)) + } + encoded, ok := secret.Data["plaintext"].(string) + if !ok || encoded == "" { + return nil, errors.New("regionkey: transit decrypt returned no plaintext") + } + cek, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("regionkey: transit decrypt plaintext: %w", err) + } + return cek, nil +} + +// keyVersionOf renders transit's key_version response field (a json.Number) +// as the "vN" tag matching the ciphertext prefix. +func keyVersionOf(v interface{}) (KeyVersion, error) { + n, ok := v.(json.Number) + if !ok { + return "", fmt.Errorf("regionkey: unexpected key_version %v (%T)", v, v) + } + return KeyVersion("v" + n.String()), nil +} + +// mapTransitDecryptError classifies a transit decrypt failure onto the +// package's sentinel errors. Transit reports every client-side decrypt +// problem as HTTP 400 with a message, so the split is by message: version +// policy refusals (rotated past min_decryption_version, or a version the key +// never had) become [ErrUnknownVersion]; every other 400 — invalid or +// tampered ciphertext, wrong derivation context — is an authentication +// failure. The message matching is pinned by unit tests so an upstream +// wording change is caught rather than silently reclassified. Non-400 errors +// (network, permission) pass through untouched. +func mapTransitDecryptError(err error) error { + var re *api.ResponseError + if !errors.As(err, &re) || re.StatusCode != 400 { + return err + } + for _, msg := range re.Errors { + if strings.Contains(msg, "disallowed by policy") || + strings.Contains(msg, "too old") || + strings.Contains(msg, "latest key version") { + return fmt.Errorf("%w: %w", ErrUnknownVersion, err) + } + } + return fmt.Errorf("%w: %w", ErrAuthentication, err) +} diff --git a/regionkey/openbao_live_test.go b/regionkey/openbao_live_test.go new file mode 100644 index 00000000..0e8b0444 --- /dev/null +++ b/regionkey/openbao_live_test.go @@ -0,0 +1,125 @@ +package regionkey_test + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/openbao/openbao/api/v2" + "github.com/stretchr/testify/require" + + "github.com/fil-forge/ingot/regionkey" +) + +// TestOpenBaoProvider_Live exercises OpenBaoProvider against a real OpenBao — +// the derived-context binding, tamper rejection, and version behavior none of +// the httptest fakes can prove. Skipped unless INGOT_TEST_BAO_ADDR names a +// reachable dev-mode server (token from INGOT_TEST_BAO_TOKEN, default "root"): +// +// docker run --rm -d -p 8200:8200 -e BAO_DEV_ROOT_TOKEN_ID=root openbao/openbao +// INGOT_TEST_BAO_ADDR=http://127.0.0.1:8200 \ +// GOWORK=off go test ./regionkey/ -run Live -v +func TestOpenBaoProvider_Live(t *testing.T) { + addr := os.Getenv("INGOT_TEST_BAO_ADDR") + if addr == "" { + t.Skip("set INGOT_TEST_BAO_ADDR to run the live OpenBao provider test") + } + token := os.Getenv("INGOT_TEST_BAO_TOKEN") + if token == "" { + token = "root" + } + ctx := context.Background() + + cfg := api.DefaultConfig() + cfg.Address = addr + client, err := api.NewClient(cfg) + require.NoError(t, err, "NewClient") + client.SetToken(token) + + // A test-owned transit mount, recreated fresh each run. + const mount = "ingot-test-transit" + const keyName = "region-kek" + _ = client.Sys().UnmountWithContext(ctx, mount) // drop any previous run's mount + require.NoError(t, client.Sys().MountWithContext(ctx, mount, &api.MountInput{Type: "transit"}), "mount transit") + t.Cleanup(func() { _ = client.Sys().UnmountWithContext(ctx, mount) }) + + // The key exactly as the RFC provisions it: AES-256-GCM, derived contexts. + _, err = client.Logical().WriteWithContext(ctx, mount+"/keys/"+keyName, map[string]interface{}{ + "type": "aes256-gcm96", + "derived": true, + }) + require.NoError(t, err, "create transit key") + + p, err := regionkey.NewOpenBaoProvider(client, mount, keyName) + require.NoError(t, err, "NewOpenBaoProvider") + + binding := testBinding(t, "blob-1") + cek := randKey(t, 32) + + t.Run("round trip", func(t *testing.T) { + wrapped, err := p.Wrap(ctx, binding, cek) + require.NoError(t, err, "Wrap") + require.True(t, strings.HasPrefix(string(wrapped.Ciphertext), "vault:v1:"), + "transit ciphertext self-describes its version: %s", wrapped.Ciphertext) + require.Equal(t, regionkey.KeyVersion("v1"), wrapped.Version) + + got, err := p.Unwrap(ctx, binding, wrapped) + require.NoError(t, err, "Unwrap") + require.Equal(t, cek, got) + }) + + t.Run("transplant fails authentication", func(t *testing.T) { + wrapped, err := p.Wrap(ctx, binding, cek) + require.NoError(t, err, "Wrap") + + otherDigest := binding + otherDigest.Digest = digestOf(t, []byte("blob-2")) + _, err = p.Unwrap(ctx, otherDigest, wrapped) + require.ErrorIs(t, err, regionkey.ErrAuthentication, "a different digest must fail authentication") + + otherSpace := testBinding(t, "blob-1") // same blob, different space + _, err = p.Unwrap(ctx, otherSpace, wrapped) + require.ErrorIs(t, err, regionkey.ErrAuthentication, "a different space must fail authentication") + }) + + t.Run("tampered ciphertext fails authentication", func(t *testing.T) { + wrapped, err := p.Wrap(ctx, binding, cek) + require.NoError(t, err, "Wrap") + wrapped.Ciphertext[len(wrapped.Ciphertext)-1] ^= 0x01 + _, err = p.Unwrap(ctx, binding, wrapped) + require.ErrorIs(t, err, regionkey.ErrAuthentication) + }) + + t.Run("rotation archives old versions until policy retires them", func(t *testing.T) { + wrappedV1, err := p.Wrap(ctx, binding, cek) + require.NoError(t, err, "Wrap under v1") + + _, err = client.Logical().WriteWithContext(ctx, mount+"/keys/"+keyName+"/rotate", nil) + require.NoError(t, err, "rotate key") + + wrappedV2, err := p.Wrap(ctx, binding, cek) + require.NoError(t, err, "Wrap after rotation") + require.Equal(t, regionkey.KeyVersion("v2"), wrappedV2.Version, "new wraps record the rotated version") + + // Archive-don't-destroy: the v1 wrap still unwraps after rotation. + got, err := p.Unwrap(ctx, binding, wrappedV1) + require.NoError(t, err, "Unwrap of pre-rotation wrap") + require.Equal(t, cek, got) + + // Retiring v1 via min_decryption_version makes the old wrap an + // unknown version — the state a rewrap campaign must clear before + // the policy advances. + _, err = client.Logical().WriteWithContext(ctx, mount+"/keys/"+keyName+"/config", map[string]interface{}{ + "min_decryption_version": 2, + }) + require.NoError(t, err, "advance min_decryption_version") + _, err = p.Unwrap(ctx, binding, wrappedV1) + require.ErrorIs(t, err, regionkey.ErrUnknownVersion, "a retired version must surface as unknown") + + // The current version is unaffected. + got, err = p.Unwrap(ctx, binding, wrappedV2) + require.NoError(t, err) + require.Equal(t, cek, got) + }) +} diff --git a/regionkey/openbao_test.go b/regionkey/openbao_test.go new file mode 100644 index 00000000..4289ce80 --- /dev/null +++ b/regionkey/openbao_test.go @@ -0,0 +1,161 @@ +package regionkey_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/openbao/openbao/api/v2" + "github.com/stretchr/testify/require" + + "github.com/fil-forge/ingot/regionkey" +) + +// fakeTransit is an httptest stand-in for OpenBao's transit endpoints. It pins +// the provider's request shape (paths, base64 field encoding) and lets the +// error-mapping tests serve exact upstream error bodies. +type fakeTransit struct { + t *testing.T + // lastPath/lastBody record the most recent request for assertions. + lastPath string + lastBody map[string]interface{} + // respond is invoked to write the response for the current request. + respond func(w http.ResponseWriter) +} + +func (f *fakeTransit) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.lastPath = r.URL.Path + f.lastBody = map[string]interface{}{} + require.NoError(f.t, json.NewDecoder(r.Body).Decode(&f.lastBody)) + f.respond(w) +} + +// newBaoClient builds an api.Client pointed at the fake server. +func newBaoClient(t *testing.T, srv *httptest.Server) *api.Client { + t.Helper() + cfg := api.DefaultConfig() + cfg.Address = srv.URL + client, err := api.NewClient(cfg) + require.NoError(t, err) + client.SetToken("test-token") + return client +} + +func respondJSON(w http.ResponseWriter, status int, body string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) +} + +func TestOpenBaoWrapRequestAndResponse(t *testing.T) { + fake := &fakeTransit{t: t, respond: func(w http.ResponseWriter) { + respondJSON(w, 200, `{"data":{"ciphertext":"vault:v3:abcdef","key_version":3}}`) + }} + srv := httptest.NewServer(fake) + defer srv.Close() + + p, err := regionkey.NewOpenBaoProvider(newBaoClient(t, srv), "region-transit", "region-kek") + require.NoError(t, err) + + binding := testBinding(t, "blob-1") + cek := randKey(t, 32) + wrapped, err := p.Wrap(context.Background(), binding, cek) + require.NoError(t, err, "Wrap") + + require.Equal(t, "/v1/region-transit/encrypt/region-kek", fake.lastPath) + require.Equal(t, base64.StdEncoding.EncodeToString(cek), fake.lastBody["plaintext"], + "plaintext must be the base64 CEK") + wantCtx := base64.StdEncoding.EncodeToString([]byte(binding.Space.String() + "\x00" + string(binding.Digest))) + require.Equal(t, wantCtx, fake.lastBody["context"], "context must be the base64 binding encoding") + + require.Equal(t, regionkey.KeyVersion("v3"), wrapped.Version, "Version comes from key_version") + require.Equal(t, []byte("vault:v3:abcdef"), wrapped.Ciphertext) +} + +func TestOpenBaoUnwrapRequestAndResponse(t *testing.T) { + cek := randKey(t, 32) + fake := &fakeTransit{t: t, respond: func(w http.ResponseWriter) { + respondJSON(w, 200, `{"data":{"plaintext":"`+base64.StdEncoding.EncodeToString(cek)+`"}}`) + }} + srv := httptest.NewServer(fake) + defer srv.Close() + + // Empty mount falls back to the conventional "transit". + p, err := regionkey.NewOpenBaoProvider(newBaoClient(t, srv), "", "region-kek") + require.NoError(t, err) + + binding := testBinding(t, "blob-1") + got, err := p.Unwrap(context.Background(), binding, + regionkey.WrappedKey{Version: "v3", Ciphertext: []byte("vault:v3:abcdef")}) + require.NoError(t, err, "Unwrap") + + require.Equal(t, "/v1/transit/decrypt/region-kek", fake.lastPath) + require.Equal(t, "vault:v3:abcdef", fake.lastBody["ciphertext"], "ciphertext is sent as the transit string") + require.Equal(t, cek, got) +} + +// The provider classifies transit decrypt failures by HTTP status and message; +// these cases pin the mapping to the upstream wordings so a change upstream +// fails here instead of silently reclassifying. +func TestOpenBaoUnwrapErrorMapping(t *testing.T) { + cases := []struct { + name string + status int + body string + sentinel error + }{ + {"tampered or wrong-binding ciphertext", 400, + `{"errors":["invalid ciphertext: could not decrypt"]}`, regionkey.ErrAuthentication}, + {"version behind min_decryption_version", 400, + `{"errors":["ciphertext or signature version is disallowed by policy (too old)"]}`, regionkey.ErrUnknownVersion}, + {"version beyond the key's latest", 400, + `{"errors":["ciphertext version is larger than the latest key version"]}`, regionkey.ErrUnknownVersion}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fake := &fakeTransit{t: t, respond: func(w http.ResponseWriter) { + respondJSON(w, tc.status, tc.body) + }} + srv := httptest.NewServer(fake) + defer srv.Close() + + p, err := regionkey.NewOpenBaoProvider(newBaoClient(t, srv), "", "region-kek") + require.NoError(t, err) + _, err = p.Unwrap(context.Background(), testBinding(t, "blob-1"), + regionkey.WrappedKey{Version: "v1", Ciphertext: []byte("vault:v1:junk")}) + require.ErrorIs(t, err, tc.sentinel) + }) + } + + t.Run("non-400 passes through unclassified", func(t *testing.T) { + fake := &fakeTransit{t: t, respond: func(w http.ResponseWriter) { + respondJSON(w, 403, `{"errors":["permission denied"]}`) + }} + srv := httptest.NewServer(fake) + defer srv.Close() + + p, err := regionkey.NewOpenBaoProvider(newBaoClient(t, srv), "", "region-kek") + require.NoError(t, err) + _, err = p.Unwrap(context.Background(), testBinding(t, "blob-1"), + regionkey.WrappedKey{Version: "v1", Ciphertext: []byte("vault:v1:junk")}) + require.Error(t, err) + require.NotErrorIs(t, err, regionkey.ErrAuthentication, "a permission failure is not an authentication verdict") + require.NotErrorIs(t, err, regionkey.ErrUnknownVersion) + }) +} + +func TestNewOpenBaoProviderValidates(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + client := newBaoClient(t, srv) + + _, err := regionkey.NewOpenBaoProvider(nil, "transit", "region-kek") + require.Error(t, err, "nil client must be rejected") + _, err = regionkey.NewOpenBaoProvider(client, "transit", "") + require.Error(t, err, "empty key name must be rejected") + _, err = regionkey.NewOpenBaoProvider(client, "", "region-kek") + require.NoError(t, err) +} diff --git a/regionkey/regionkey.go b/regionkey/regionkey.go index 53ad3160..c78b17d1 100644 --- a/regionkey/regionkey.go +++ b/regionkey/regionkey.go @@ -2,105 +2,135 @@ // design: the read-path copy of a per-object content-encryption key (CEK), // wrapped to the region's own key and stored in Ingot's local database so a // region can serve GETs without a synchronous call to any central FilOne -// service (see the RFC, "Region wrap"). +// service (see the encryption RFC, "Region wrap"). +// +// Per the regional security and key management RFC (which amends the +// encryption RFC's original A256KW choice), the region wrap is AES-256-GCM, +// context-bound to the blob it protects: the (space, blob digest) pair is +// bound into the wrap, so wrap material transplanted between DB rows fails to +// unwrap. In production the wrap runs inside the region's secrets manager +// (OpenBao transit, aes256-gcm96 with derived per-context keys), so the +// region KEK is non-exportable and never enters Ingot's process; host +// hardening, not process-level machinery, covers memory. This package +// defines the seam Ingot calls, plus an [InProcessProvider] with the same +// semantics for tests and development. // // This is Ingot-side key-management infrastructure, not part of the FEE -// library. Per the RFC the region wrap is not a COSE recipient and never enters -// the envelope; it is stored out-of-band as Ingot's own bytes in Ingot's DB, so -// nothing here touches the COSE envelope, recipients, or the fee package's -// public API. The package borrows one primitive — A256KW (RFC 3394 AES Key -// Wrap) — from fee/aeskw, which lives in the fee tree only because the -// tenant-recipient wrap (fee/ecdhkw, ECDH-ES+A256KW) built it there as a shared -// building block. Unlike that asymmetric tenant wrap, the region wrap is -// symmetric: the CEK is wrapped straight under a 256-bit region key-encryption -// key (KEK). +// library. Per the RFC the region wrap is not a COSE recipient and never +// enters the envelope; it is stored out-of-band as Ingot's own bytes in +// Ingot's DB, so nothing here touches the COSE envelope, recipients, or the +// fee package's public API. Unlike the asymmetric tenant-recipient wrap in +// the envelope header, the region wrap is symmetric: the CEK is wrapped +// straight under a 256-bit region key-encryption key (KEK). // // # The Provider seam // -// [Provider] is the interface Ingot calls. It is scope-parameterized — -// Wrap(ctx, scope, cek) / Unwrap(ctx, scope, wrapped) — so that the question of -// how many physical region KEKs exist stays entirely inside the implementation. -// v1 holds a single region-wide key and every [Scope] resolves to it; if the -// region-key-cardinality decision (FIL-572) later splits the key per bucket or -// per tenant, only the Provider implementation changes, not any call site. A -// future non-exportable PKCS#11/HSM Provider can ignore the scope entirely, -// since non-exportability already gives the blast-radius protection multiple -// software keys exist to approximate. Because the seam is an interface, a mock, -// the in-process [SoftwareProvider], or an HSM-backed provider are -// interchangeable without touching Ingot's wrap/unwrap call sites. +// [Provider] is the interface Ingot calls: Wrap(ctx, binding, cek) / +// Unwrap(ctx, binding, wrapped), where [BindingContext] is the (space, blob +// digest) pair the wrap is bound to. How many physical region KEKs exist +// stays entirely inside the implementation: v1 holds a single region-wide +// key and every binding resolves to it; if the region-key-cardinality decision +// (FIL-572) later splits the key per bucket or per tenant, only the Provider +// implementation changes, not any call site. Because the seam is an +// interface, the [InProcessProvider] and the production [OpenBaoProvider] +// are interchangeable without touching Ingot's wrap/unwrap call sites. // // # Versioning // // Every [WrappedKey] records the [KeyVersion] of the region KEK that produced -// it. Ingot stores that version alongside the wrapped bytes, so a later Unwrap -// selects the same KEK version even across a region-key rotation, and so old -// key versions can be archived rather than destroyed. This is the RFC's -// rotation-readiness groundwork ("Every envelope and DB row carries a versioned -// kid from day one"); the rotation flow itself ships later. -// -// # Exposure hygiene -// -// A256KW is not natively offered by Vault Transit or most software KMS backends -// — it is primarily an HSM/PKCS#11 primitive — so the v1 [SoftwareProvider] -// must import the raw KEK into process memory to perform the wrap. It minimizes -// the exposure window: it fetches the KEK from a [KEKSource] only for the -// duration of a single wrap or unwrap, holds it in a locked, non-swappable -// buffer ([KEK], backed by mlock), and zeroes that buffer immediately after the -// operation on every path, including errors. This bounds raw-KEK exposure to -// microseconds per operation and is the primary practical mitigation until an -// HSM-backed Provider removes the export requirement entirely. +// it. Ingot stores that version alongside the wrapped bytes, so a later +// Unwrap selects the same KEK version even across a region-key rotation, and +// so old key versions can be archived rather than destroyed. This is the +// encryption RFC's rotation-readiness groundwork ("Every envelope and DB row +// carries a versioned kid from day one"); the rotation flow itself ships +// later. package regionkey import ( "context" "errors" + + "github.com/fil-forge/ucantone/did" + "github.com/multiformats/go-multihash" ) -// ErrUnknownVersion is returned by a [KEKSource] (and surfaced by -// [Provider.Unwrap]) when asked for a region KEK version the source does not -// hold — for example a wrap produced by a different region, or a version that -// was never provisioned. Callers can match it with [errors.Is]. +// ErrUnknownVersion is returned by [Provider.Unwrap] when asked to unwrap +// under a region KEK version the provider does not hold — for example a wrap +// produced by a different region, or a version that was never provisioned. +// Callers can match it with [errors.Is]. // -// A wrong-key unwrap where the source *does* hold the named version instead -// fails the A256KW integrity check and surfaces as fee/aeskw.ErrIntegrity -// (wrapped); the two cases are distinct. +// A wrong-key or wrong-binding unwrap where the provider *does* hold the +// named version instead fails ciphertext authentication and surfaces as +// [ErrAuthentication]; the two cases are distinct. var ErrUnknownVersion = errors.New("regionkey: unknown key version") -// Scope identifies which region key protects a CEK. It carries the dimensions a -// Provider might key on if the region-key-cardinality decision (FIL-572) later -// splits the single region key; the region itself is implicit, since a Provider -// is bound to one region. In v1 there is exactly one region key and every Scope -// resolves to it, so the fields are advisory — the [SoftwareProvider] ignores -// them — but threading the Scope through from day one means that decision can -// change without altering any wrap/unwrap call site in Ingot. -type Scope struct { - // Tenant is the DID of the tenant that owns the object's bucket, if known. - Tenant string - // Bucket is the name of the bucket the object belongs to, if known. - Bucket string +// ErrAuthentication is returned (wrapped) by [Provider.Unwrap] when the +// wrapped bytes fail to authenticate: the KEK is wrong for this wrap, the +// binding context differs from the one the CEK was wrapped under, or the +// bytes were tampered with. The causes are deliberately indistinguishable — +// AES-256-GCM authenticates key, binding context, and ciphertext as one. +var ErrAuthentication = errors.New("regionkey: ciphertext authentication failed") + +// BindingContext is what a region wrap is bound to: the space and blob digest +// the wrapped CEK protects. Per the regional security RFC the wrap is +// context-bound to exactly this pair, so a wrapped CEK authenticates only +// against its own (Space, Digest) and wrap material transplanted between DB +// rows fails with [ErrAuthentication]. Wrap and Unwrap must be given the +// same BindingContext. +// +// Whether different bindings also resolve to different physical KEKs remains +// the Provider's business (the region-key-cardinality decision, FIL-572); v1 +// implementations hold a single region-wide KEK. +// +// Every Provider implementation encodes the pair with [bindingBytes], so the +// bound context is byte-identical across providers. +type BindingContext struct { + // Space is the Forge space that owns the blob. + Space did.DID + // Digest is the ciphertext blob's multihash, as keyed in + // blob_encryption_params. + Digest multihash.Multihash +} + +// bindingBytes is the canonical byte encoding of a binding context, shared by +// every Provider implementation: [InProcessProvider] feeds it to HKDF as the +// subkey info, [OpenBaoProvider] sends it as the transit derivation context. +// A DID string contains no NUL, so the separator makes the encoding +// unambiguous. +func bindingBytes(b BindingContext) []byte { + space := b.Space.String() + out := make([]byte, 0, len(space)+1+len(b.Digest)) + out = append(out, space...) + out = append(out, 0) + out = append(out, b.Digest...) + return out } // KeyVersion identifies a specific version of a region KEK. It is stored // alongside the wrapped CEK (see [WrappedKey]) so that [Provider.Unwrap] can -// select the KEK version that produced a wrap, which is what lets a region-key -// rotation archive old versions instead of destroying them. The empty string -// is not a valid version. +// select the KEK version that produced a wrap, which is what lets a +// region-key rotation archive old versions instead of destroying them. The +// empty string is not a valid version. type KeyVersion string // WrappedKey is a region-wrapped CEK together with the version of the region // KEK that wrapped it. Ingot persists both fields in its local database (the -// wrapped-CEK bytes and a key-version column on the object's part row); Unwrap -// needs the version to recover the CEK after a rotation. +// region_wrapped_cek and region_key_version columns of +// blob_encryption_params); Unwrap needs the version to recover the CEK after +// a rotation. // // The wrapped CEK travels only in Ingot's mutable local database and never -// enters the FEE envelope or the MST — it is the shreddable, rotatable read-path -// copy, kept separate from the immutable tenant recipient that lives in the -// envelope. +// enters the FEE envelope or the MST — it is the shreddable, rotatable +// read-path copy, kept separate from the immutable tenant recipient that +// lives in the envelope. type WrappedKey struct { // Version identifies the region KEK that produced Ciphertext. Version KeyVersion - // Ciphertext is the A256KW (RFC 3394) output: the CEK wrapped under the - // region KEK, exactly 8 bytes longer than the CEK. + // Ciphertext is the CEK wrapped under the region KEK: opaque to callers + // and specific to the Provider that produced it. [InProcessProvider] + // emits nonce ‖ AES-256-GCM ciphertext ‖ tag (28 bytes longer than the + // CEK); [OpenBaoProvider] emits the transit ciphertext string's bytes + // ("vault:vN:…"). Ciphertext []byte } @@ -110,38 +140,18 @@ type WrappedKey struct { // where they live, so they are interchangeable without changing call sites; // see the package doc. type Provider interface { - // Wrap encrypts cek under the current region KEK for scope and returns the - // wrapped CEK tagged with that KEK's version. cek must be a valid AES key — - // a multiple of 8 bytes, at least 16 (FilOne CEKs are 32). The cek slice is - // neither retained nor modified. - Wrap(ctx context.Context, scope Scope, cek []byte) (WrappedKey, error) + // Wrap encrypts cek under the current region KEK, bound to binding, and + // returns the wrapped CEK tagged with that KEK's version. cek is treated + // as opaque plaintext (FilOne CEKs are 32 bytes); the slice is neither + // retained nor modified. + Wrap(ctx context.Context, binding BindingContext, cek []byte) (WrappedKey, error) - // Unwrap reverses Wrap, recovering the CEK from wrapped using the region KEK - // named by wrapped.Version for scope. It returns [ErrUnknownVersion] if no + // Unwrap reverses Wrap, recovering the CEK from wrapped using the region + // KEK named by wrapped.Version, bound to binding — which must equal the + // binding the CEK was wrapped under. It returns [ErrUnknownVersion] if no // KEK of that version is available, and an error wrapping - // fee/aeskw.ErrIntegrity if the KEK is wrong for this wrap or the wrapped - // bytes were tampered with. The returned CEK is freshly allocated and owned - // by the caller, who should zero it once done. - Unwrap(ctx context.Context, scope Scope, wrapped WrappedKey) ([]byte, error) -} - -// KEKSource custodies the raw region key-encryption keys the [SoftwareProvider] -// imports to perform A256KW in software. It is the Vault-style seam — the same -// fetch-by-identity shape Hilt uses for secret custody — so an in-memory -// single-key source ([StaticKEKSource]) backs v1 and a Vault-backed source can -// replace it without touching the Provider. A future PKCS#11/HSM Provider -// avoids this seam entirely by never exporting its key. -// -// Both methods return the KEK in a locked [KEK] buffer whose ownership passes -// to the caller. The caller MUST call [KEK.Destroy] — which zeroes and unlocks -// the memory — as soon as the single wrap or unwrap completes; [SoftwareProvider] -// does so with a defer on every path. -type KEKSource interface { - // CurrentKEK returns the region KEK that new wraps for scope must use, - // together with the version that names it. The version must be non-empty. - CurrentKEK(ctx context.Context, scope Scope) (KeyVersion, *KEK, error) - - // KEKAt returns the region KEK identified by version for scope. It returns - // [ErrUnknownVersion] if the source holds no such version. - KEKAt(ctx context.Context, scope Scope, version KeyVersion) (*KEK, error) + // [ErrAuthentication] if the KEK or binding is wrong for this wrap or the + // wrapped bytes were tampered with. The returned CEK is freshly allocated + // and owned by the caller, who should zero it once done. + Unwrap(ctx context.Context, binding BindingContext, wrapped WrappedKey) ([]byte, error) } diff --git a/regionkey/regionkey_test.go b/regionkey/regionkey_test.go index 240b2846..7b589262 100644 --- a/regionkey/regionkey_test.go +++ b/regionkey/regionkey_test.go @@ -7,7 +7,8 @@ import ( "testing" "github.com/fil-forge/ingot/regionkey" - "github.com/filecoin-project/go-fee/aeskw" + "github.com/fil-forge/libforge/testutil" + "github.com/multiformats/go-multihash" "github.com/stretchr/testify/require" ) @@ -20,344 +21,249 @@ func randKey(t *testing.T, n int) []byte { return b } -// newProvider builds a SoftwareProvider over a single-key StaticKEKSource with -// the given version and KEK, registering Close for cleanup. -func newProvider(t *testing.T, version regionkey.KeyVersion, kek []byte) *regionkey.SoftwareProvider { +// digestOf returns the sha256 multihash of data, the digest form Ingot keys +// blobs by. +func digestOf(t *testing.T, data []byte) multihash.Multihash { t.Helper() - src, err := regionkey.NewStaticKEKSource(version, kek) - require.NoError(t, err, "NewStaticKEKSource") - t.Cleanup(func() { _ = src.Close() }) - return regionkey.NewSoftwareProvider(src) + mh, err := multihash.Sum(data, multihash.SHA2_256, -1) + require.NoError(t, err, "multihash.Sum") + return mh } -// AC: "I can wrap a CEK through the key-provider interface (with a scope +// testBinding returns a binding over a fresh space and the digest of blob. +func testBinding(t *testing.T, blob string) regionkey.BindingContext { + t.Helper() + return regionkey.BindingContext{Space: testutil.RandomDID(t), Digest: digestOf(t, []byte(blob))} +} + +// newProvider builds an InProcessProvider holding a single key under the +// given version. +func newProvider(t *testing.T, version regionkey.KeyVersion, kek []byte) *regionkey.InProcessProvider { + t.Helper() + p, err := regionkey.NewInProcessProvider(version, kek) + require.NoError(t, err, "NewInProcessProvider") + return p +} + +// AC: "I can wrap a CEK through the key-provider interface (with a binding // parameter) and unwrap it back to the original CEK." func TestWrapUnwrapRoundTrip(t *testing.T) { ctx := context.Background() p := newProvider(t, "region#1", randKey(t, regionkey.KEKLen)) - scope := regionkey.Scope{Tenant: "did:web:example.com:tenant:acme", Bucket: "photos"} + binding := testBinding(t, "blob-1") cek := randKey(t, 32) - wrapped, err := p.Wrap(ctx, scope, cek) + wrapped, err := p.Wrap(ctx, binding, cek) require.NoError(t, err, "Wrap") - require.Len(t, wrapped.Ciphertext, len(cek)+8, "A256KW output is 8 bytes longer than the CEK") + require.Len(t, wrapped.Ciphertext, len(cek)+28, "GCM output is nonce (12) + ciphertext + tag (16)") require.NotEqual(t, cek, wrapped.Ciphertext, "wrapped bytes must not equal the plaintext CEK") - got, err := p.Unwrap(ctx, scope, wrapped) + got, err := p.Unwrap(ctx, binding, wrapped) require.NoError(t, err, "Unwrap") require.Equal(t, cek, got, "round-trip must recover the original CEK") } -// AC: "When I attempt to unwrap with a provider holding a different KEK for that -// scope, unwrap returns an error." Both providers advertise the same version, -// so the version lookup succeeds and the A256KW integrity check is what rejects -// the wrong key. +// AC: "When I attempt to unwrap with a provider holding a different KEK for +// that binding, unwrap returns an error." Both providers advertise the same +// version, so the version lookup succeeds and GCM authentication is what +// rejects the wrong key. func TestUnwrapWithDifferentKEK(t *testing.T) { ctx := context.Background() - scope := regionkey.Scope{} + binding := testBinding(t, "blob-1") cek := randKey(t, 32) wrapper := newProvider(t, "region#1", randKey(t, regionkey.KEKLen)) otherKEK := newProvider(t, "region#1", randKey(t, regionkey.KEKLen)) - wrapped, err := wrapper.Wrap(ctx, scope, cek) + wrapped, err := wrapper.Wrap(ctx, binding, cek) require.NoError(t, err, "Wrap") - got, err := otherKEK.Unwrap(ctx, scope, wrapped) + got, err := otherKEK.Unwrap(ctx, binding, wrapped) require.Error(t, err, "unwrap with a different KEK must fail") - require.ErrorIs(t, err, aeskw.ErrIntegrity, "wrong same-version KEK surfaces as an integrity failure") + require.ErrorIs(t, err, regionkey.ErrAuthentication, "wrong same-version KEK surfaces as an authentication failure") require.Nil(t, got) } +// The RFC's transplant property: the wrap is context-bound to (space, blob +// digest), so wrap material moved to a different blob's row — same KEK, same +// version — fails authentication rather than yielding the CEK. +func TestUnwrapWrongBindingFails(t *testing.T) { + ctx := context.Background() + p := newProvider(t, "region#1", randKey(t, regionkey.KEKLen)) + cek := randKey(t, 32) + + binding := testBinding(t, "blob-1") + wrapped, err := p.Wrap(ctx, binding, cek) + require.NoError(t, err, "Wrap") + + otherDigest := binding + otherDigest.Digest = digestOf(t, []byte("blob-2")) + _, err = p.Unwrap(ctx, otherDigest, wrapped) + require.ErrorIs(t, err, regionkey.ErrAuthentication, "a different digest must fail authentication") + + otherSpace := binding + otherSpace.Space = testutil.RandomDID(t) + _, err = p.Unwrap(ctx, otherSpace, wrapped) + require.ErrorIs(t, err, regionkey.ErrAuthentication, "a different space must fail authentication") +} + +// Tampered wrap material fails authentication. +func TestUnwrapTamperedCiphertextFails(t *testing.T) { + ctx := context.Background() + p := newProvider(t, "region#1", randKey(t, regionkey.KEKLen)) + binding := testBinding(t, "blob-1") + + wrapped, err := p.Wrap(ctx, binding, randKey(t, 32)) + require.NoError(t, err, "Wrap") + wrapped.Ciphertext[len(wrapped.Ciphertext)-1] ^= 0x01 + + _, err = p.Unwrap(ctx, binding, wrapped) + require.ErrorIs(t, err, regionkey.ErrAuthentication) + + // A wrap too short to even carry a nonce is reported the same way. + _, err = p.Unwrap(ctx, binding, regionkey.WrappedKey{Version: "region#1", Ciphertext: []byte{0x01}}) + require.ErrorIs(t, err, regionkey.ErrAuthentication) +} + // AC: "When I attempt to unwrap ... a different KEK ..." — the sibling case // where the second provider does not even hold the wrap's version (e.g. data -// wrapped by a different region). This is reported as ErrUnknownVersion, which -// is distinct from the integrity failure above. +// wrapped by a different region). This is reported as ErrUnknownVersion, +// which is distinct from the authentication failure above. func TestUnwrapUnknownVersion(t *testing.T) { ctx := context.Background() - scope := regionkey.Scope{} + binding := testBinding(t, "blob-1") cek := randKey(t, 32) wrapper := newProvider(t, "region#1", randKey(t, regionkey.KEKLen)) newRegion := newProvider(t, "region#2", randKey(t, regionkey.KEKLen)) - wrapped, err := wrapper.Wrap(ctx, scope, cek) + wrapped, err := wrapper.Wrap(ctx, binding, cek) require.NoError(t, err, "Wrap") - _, err = newRegion.Unwrap(ctx, scope, wrapped) + _, err = newRegion.Unwrap(ctx, binding, wrapped) require.ErrorIs(t, err, regionkey.ErrUnknownVersion, "a version the source does not hold must report ErrUnknownVersion") } -// AC: "I can read the key version stored alongside the wrapped bytes and confirm -// it matches the KEK version used to wrap." +// AC: "I can read the key version stored alongside the wrapped bytes and +// confirm it matches the KEK version used to wrap." func TestWrappedKeyRecordsVersion(t *testing.T) { ctx := context.Background() const version regionkey.KeyVersion = "region#7" p := newProvider(t, version, randKey(t, regionkey.KEKLen)) - wrapped, err := p.Wrap(ctx, regionkey.Scope{}, randKey(t, 32)) + wrapped, err := p.Wrap(ctx, testBinding(t, "blob-1"), randKey(t, 32)) require.NoError(t, err, "Wrap") require.Equal(t, version, wrapped.Version, "the wrap must record the KEK version that produced it") } -// Rotation-readiness: an archived (non-current) key version still unwraps, and -// the current version is what new wraps record. This exercises the versioning -// groundwork the RFC requires from day one, using a multi-version source. +// Rotation-readiness: an archived (non-current) key version still unwraps, +// and the current version is what new wraps record. This exercises the +// versioning groundwork the RFC requires from day one. func TestVersionSelectionAcrossRotation(t *testing.T) { ctx := context.Background() - scope := regionkey.Scope{} + binding := testBinding(t, "blob-1") cek := randKey(t, 32) - oldKEK := randKey(t, regionkey.KEKLen) - newKEK := randKey(t, regionkey.KEKLen) - - // Wrap under the old key, as an existing DB row would have been. - oldProvider := newProvider(t, "region#1", oldKEK) - wrappedOld, err := oldProvider.Wrap(ctx, scope, cek) + // Wrap under the original key, as an existing DB row would have been. + p := newProvider(t, "region#1", randKey(t, regionkey.KEKLen)) + wrappedOld, err := p.Wrap(ctx, binding, cek) require.NoError(t, err, "Wrap under old key") - // After a rotation the source's current version is region#2 but it retains - // region#1 (archive-don't-destroy). - src := &mapKEKSource{ - current: "region#2", - keys: map[regionkey.KeyVersion][]byte{ - "region#1": oldKEK, - "region#2": newKEK, - }, - } - p := regionkey.NewSoftwareProvider(src) + // Rotate: region#2 becomes current, region#1 is archived, not destroyed. + require.NoError(t, p.Rotate("region#2", randKey(t, regionkey.KEKLen)), "Rotate") // New wraps use the current version. - wrappedNew, err := p.Wrap(ctx, scope, cek) + wrappedNew, err := p.Wrap(ctx, binding, cek) require.NoError(t, err, "Wrap after rotation") require.Equal(t, regionkey.KeyVersion("region#2"), wrappedNew.Version) // The archived version still unwraps existing data. - gotOld, err := p.Unwrap(ctx, scope, wrappedOld) + gotOld, err := p.Unwrap(ctx, binding, wrappedOld) require.NoError(t, err, "Unwrap of archived-version data") require.Equal(t, cek, gotOld) // And so does the freshly wrapped data. - gotNew, err := p.Unwrap(ctx, scope, wrappedNew) + gotNew, err := p.Unwrap(ctx, binding, wrappedNew) require.NoError(t, err, "Unwrap of current-version data") require.Equal(t, cek, gotNew) } -// AC: "The raw KEK is held in process memory only for the duration of a single -// wrap/unwrap call, in a locked buffer, and is zeroed immediately afterward on -// both success and error paths." The inspectable source captures every KEK the -// provider imports; after each call the buffer must be zeroed (Destroy ran). -func TestKEKZeroedAfterOperation(t *testing.T) { - ctx := context.Background() - scope := regionkey.Scope{} - - t.Run("success path", func(t *testing.T) { - inner, err := regionkey.NewStaticKEKSource("region#1", randKey(t, regionkey.KEKLen)) - require.NoError(t, err) - t.Cleanup(func() { _ = inner.Close() }) - spy := &inspectingKEKSource{inner: inner} - p := regionkey.NewSoftwareProvider(spy) - - wrapped, err := p.Wrap(ctx, scope, randKey(t, 32)) - require.NoError(t, err, "Wrap") - _, err = p.Unwrap(ctx, scope, wrapped) - require.NoError(t, err, "Unwrap") - - spy.assertAllZeroed(t) - }) - - t.Run("error path", func(t *testing.T) { - inner, err := regionkey.NewStaticKEKSource("region#1", randKey(t, regionkey.KEKLen)) - require.NoError(t, err) - t.Cleanup(func() { _ = inner.Close() }) - spy := &inspectingKEKSource{inner: inner} - p := regionkey.NewSoftwareProvider(spy) - - // A 20-byte CEK is not block-aligned, so aeskw.Wrap fails after the KEK - // has been imported — the defer must still zero it. - _, err = p.Wrap(ctx, scope, make([]byte, 20)) - require.Error(t, err, "Wrap with an invalid CEK must fail") - - spy.assertAllZeroed(t) - }) -} - // AC: "I can swap in a different key-provider implementation ... without // changing any wrap/unwrap call sites." roundTrip is written against the -// regionkey.Provider interface only; it drives both the real SoftwareProvider and -// an unrelated mock without change. +// regionkey.Provider interface only; it drives both the real +// InProcessProvider and an unrelated mock without change. func TestProviderIsSwappable(t *testing.T) { ctx := context.Background() - scope := regionkey.Scope{Bucket: "b"} + binding := testBinding(t, "blob-1") cek := randKey(t, 32) roundTrip := func(t *testing.T, p regionkey.Provider) []byte { t.Helper() - wrapped, err := p.Wrap(ctx, scope, cek) + wrapped, err := p.Wrap(ctx, binding, cek) require.NoError(t, err, "Wrap") - got, err := p.Unwrap(ctx, scope, wrapped) + got, err := p.Unwrap(ctx, binding, wrapped) require.NoError(t, err, "Unwrap") return got } - software := newProvider(t, "region#1", randKey(t, regionkey.KEKLen)) - require.Equal(t, cek, roundTrip(t, software), "software provider round-trips") + inProcess := newProvider(t, "region#1", randKey(t, regionkey.KEKLen)) + require.Equal(t, cek, roundTrip(t, inProcess), "in-process provider round-trips") // The same call site drives a completely different implementation. require.Equal(t, cek, roundTrip(t, echoProvider{}), "mock provider drives the same call site") } -// AC: "The scope parameter is threaded through end to end even though v1 always -// resolves it to a single region-wide key." A wrap under one scope unwraps -// under a different scope, because the single-key source ignores the scope. -func TestScopeThreadedButResolvesToSingleKey(t *testing.T) { +// v1 holds a single region-wide KEK: every binding wraps under the same key +// version, even though each wrap is bound to its own binding. +func TestBindingsShareSingleKeyVersion(t *testing.T) { ctx := context.Background() p := newProvider(t, "region#1", randKey(t, regionkey.KEKLen)) cek := randKey(t, 32) - wrapScope := regionkey.Scope{Tenant: "did:web:example.com:tenant:a", Bucket: "alpha"} - readScope := regionkey.Scope{Tenant: "did:web:example.com:tenant:z", Bucket: "omega"} - - wrapped, err := p.Wrap(ctx, wrapScope, cek) - require.NoError(t, err, "Wrap under one scope") + bindingA := testBinding(t, "blob-a") + bindingB := testBinding(t, "blob-b") - got, err := p.Unwrap(ctx, readScope, wrapped) - require.NoError(t, err, "Unwrap under a different scope") - require.Equal(t, cek, got, "v1 resolves every scope to the single region key") - - // Two different scopes both wrap under the same single key/version. - w1, err := p.Wrap(ctx, wrapScope, cek) + w1, err := p.Wrap(ctx, bindingA, cek) require.NoError(t, err) - w2, err := p.Wrap(ctx, readScope, cek) + w2, err := p.Wrap(ctx, bindingB, cek) require.NoError(t, err) - require.Equal(t, w1.Version, w2.Version, "all scopes share the single region key version in v1") -} + require.Equal(t, w1.Version, w2.Version, "all bindings share the single region key version in v1") -func TestWrapRejectsEmptyVersionFromSource(t *testing.T) { - ctx := context.Background() - src := &mapKEKSource{current: "", keys: map[regionkey.KeyVersion][]byte{"": randKey(t, regionkey.KEKLen)}} - p := regionkey.NewSoftwareProvider(src) - - _, err := p.Wrap(ctx, regionkey.Scope{}, randKey(t, 32)) - require.Error(t, err, "a source advertising an empty version must be rejected") + // Each wrap unwraps under its own binding only. + got, err := p.Unwrap(ctx, bindingA, w1) + require.NoError(t, err) + require.Equal(t, cek, got) + _, err = p.Unwrap(ctx, bindingA, w2) + require.ErrorIs(t, err, regionkey.ErrAuthentication, "binding B's wrap must not unwrap under binding A") } -// A StaticKEKSource is nil-safe to Close and, once closed, refuses to hand out -// its (now-wiped) key rather than silently wrapping under zeroes. -func TestStaticKEKSourceCloseSafety(t *testing.T) { - ctx := context.Background() - - // Zero-value source Closes without panicking. - require.NotPanics(t, func() { _ = (®ionkey.StaticKEKSource{}).Close() }) +// NewInProcessProvider and Rotate validate their inputs: a KEK of the wrong +// length, an empty version, or a duplicate version is rejected. +func TestNewInProcessProviderValidates(t *testing.T) { + _, err := regionkey.NewInProcessProvider("", randKey(t, regionkey.KEKLen)) + require.Error(t, err, "empty version must be rejected") + _, err = regionkey.NewInProcessProvider("region#1", randKey(t, regionkey.KEKLen-1)) + require.Error(t, err, "short KEK must be rejected") - src, err := regionkey.NewStaticKEKSource("region#1", randKey(t, regionkey.KEKLen)) + p, err := regionkey.NewInProcessProvider("region#1", randKey(t, regionkey.KEKLen)) require.NoError(t, err) - - require.NoError(t, src.Close()) - require.NotPanics(t, func() { _ = src.Close() }, "Close must be idempotent") - - _, _, err = src.CurrentKEK(ctx, regionkey.Scope{}) - require.Error(t, err, "using a closed source must error, not hand out a wiped key") - _, err = src.KEKAt(ctx, regionkey.Scope{}, "region#1") - require.Error(t, err, "using a closed source must error") -} - -// A misbehaving KEKSource that returns a nil KEK with no error must surface an -// error, not nil-panic the provider's deferred Destroy. -func TestProviderRejectsNilKEKFromSource(t *testing.T) { - ctx := context.Background() - p := regionkey.NewSoftwareProvider(nilKEKSource{}) - - require.NotPanics(t, func() { - _, err := p.Wrap(ctx, regionkey.Scope{}, randKey(t, 32)) - require.Error(t, err, "Wrap must reject a nil KEK from the source") - }) - require.NotPanics(t, func() { - _, err := p.Unwrap(ctx, regionkey.Scope{}, regionkey.WrappedKey{Version: "region#1", Ciphertext: make([]byte, 40)}) - require.Error(t, err, "Unwrap must reject a nil KEK from the source") - }) + require.Error(t, p.Rotate("", randKey(t, regionkey.KEKLen)), "Rotate must reject an empty version") + require.Error(t, p.Rotate("region#2", randKey(t, regionkey.KEKLen-1)), "Rotate must reject a short KEK") + require.Error(t, p.Rotate("region#1", randKey(t, regionkey.KEKLen)), "Rotate must reject a duplicate version") + require.NoError(t, p.Rotate("region#2", randKey(t, regionkey.KEKLen))) } // --- test doubles --- -// mapKEKSource is a KEKSource holding multiple key versions in memory, used to -// exercise version selection and rotation. It builds locked KEKs via the -// exported regionkey.NewKEK, exactly as an out-of-package (e.g. Vault-backed) -// source would. -type mapKEKSource struct { - current regionkey.KeyVersion - keys map[regionkey.KeyVersion][]byte -} - -func (s *mapKEKSource) CurrentKEK(ctx context.Context, scope regionkey.Scope) (regionkey.KeyVersion, *regionkey.KEK, error) { - k, err := s.KEKAt(ctx, scope, s.current) - if err != nil { - return "", nil, err - } - return s.current, k, nil -} - -func (s *mapKEKSource) KEKAt(ctx context.Context, scope regionkey.Scope, version regionkey.KeyVersion) (*regionkey.KEK, error) { - raw, ok := s.keys[version] - if !ok { - return nil, regionkey.ErrUnknownVersion - } - return regionkey.NewKEK(raw) -} - -// inspectingKEKSource wraps another source and retains every KEK it hands out -// so a test can assert the provider zeroed them after use. -type inspectingKEKSource struct { - inner regionkey.KEKSource - handed []*regionkey.KEK -} - -func (s *inspectingKEKSource) CurrentKEK(ctx context.Context, scope regionkey.Scope) (regionkey.KeyVersion, *regionkey.KEK, error) { - v, k, err := s.inner.CurrentKEK(ctx, scope) - if k != nil { - s.handed = append(s.handed, k) - } - return v, k, err -} - -func (s *inspectingKEKSource) KEKAt(ctx context.Context, scope regionkey.Scope, version regionkey.KeyVersion) (*regionkey.KEK, error) { - k, err := s.inner.KEKAt(ctx, scope, version) - if k != nil { - s.handed = append(s.handed, k) - } - return k, err -} - -func (s *inspectingKEKSource) assertAllZeroed(t *testing.T) { - t.Helper() - require.NotEmpty(t, s.handed, "expected the provider to import at least one KEK") - for i, k := range s.handed { - require.Equalf(t, make([]byte, regionkey.KEKLen), k.Bytes(), "KEK %d must be zeroed after the operation", i) - } -} - -// echoProvider is a minimal alternative Provider implementation. It performs no -// real cryptography; it exists only to prove a call site written against +// echoProvider is a minimal alternative Provider implementation. It performs +// no real cryptography; it exists only to prove a call site written against // regionkey.Provider drives any implementation unchanged. type echoProvider struct{} -func (echoProvider) Wrap(ctx context.Context, scope regionkey.Scope, cek []byte) (regionkey.WrappedKey, error) { +func (echoProvider) Wrap(ctx context.Context, binding regionkey.BindingContext, cek []byte) (regionkey.WrappedKey, error) { return regionkey.WrappedKey{Version: "echo", Ciphertext: bytes.Clone(cek)}, nil } -func (echoProvider) Unwrap(ctx context.Context, scope regionkey.Scope, wrapped regionkey.WrappedKey) ([]byte, error) { +func (echoProvider) Unwrap(ctx context.Context, binding regionkey.BindingContext, wrapped regionkey.WrappedKey) ([]byte, error) { return bytes.Clone(wrapped.Ciphertext), nil } - -// nilKEKSource is a deliberately misbehaving KEKSource: it reports no error but -// returns a nil *KEK, the contract violation the provider's nil guards defend -// against. -type nilKEKSource struct{} - -func (nilKEKSource) CurrentKEK(ctx context.Context, scope regionkey.Scope) (regionkey.KeyVersion, *regionkey.KEK, error) { - return "region#1", nil, nil -} - -func (nilKEKSource) KEKAt(ctx context.Context, scope regionkey.Scope, version regionkey.KeyVersion) (*regionkey.KEK, error) { - return nil, nil -} diff --git a/regionkey/software.go b/regionkey/software.go deleted file mode 100644 index c64b5cfa..00000000 --- a/regionkey/software.go +++ /dev/null @@ -1,88 +0,0 @@ -package regionkey - -import ( - "context" - "fmt" - - "github.com/filecoin-project/go-fee/aeskw" -) - -// SoftwareProvider is the v1 region [Provider]: it performs A256KW in process, -// importing the KEK from a [KEKSource] into a locked [KEK] buffer only for the -// duration of a single wrap or unwrap and zeroing it immediately afterward. It -// is the software stand-in for a future non-exportable PKCS#11/HSM provider, -// necessary because A256KW is an HSM/PKCS#11 primitive that Vault Transit and -// most software KMS backends do not offer natively. -// -// How many physical region KEKs exist is entirely the KEKSource's business, so -// swapping single-key custody for a per-bucket or per-tenant source (FIL-572) -// needs no change here or in Ingot. -type SoftwareProvider struct { - source KEKSource -} - -var _ Provider = (*SoftwareProvider)(nil) - -// NewSoftwareProvider returns a SoftwareProvider that draws region KEKs from -// source. -func NewSoftwareProvider(source KEKSource) *SoftwareProvider { - return &SoftwareProvider{source: source} -} - -// Wrap implements [Provider.Wrap]. It fetches the current region KEK for scope, -// wraps cek under it with A256KW, and tags the result with the KEK's version. -// The KEK is held in a locked buffer only for this call and zeroed before Wrap -// returns, on both the success and error paths. -func (p *SoftwareProvider) Wrap(ctx context.Context, scope Scope, cek []byte) (WrappedKey, error) { - if p.source == nil { - return WrappedKey{}, fmt.Errorf("regionkey: provider has no KEK source") - } - version, kek, err := p.source.CurrentKEK(ctx, scope) - if err != nil { - return WrappedKey{}, fmt.Errorf("regionkey: fetching current KEK: %w", err) - } - // A well-behaved source returns a non-nil KEK on the no-error path; guard - // against a misbehaving custom KEKSource so the deferred Destroy below - // cannot nil-panic. - if kek == nil { - return WrappedKey{}, fmt.Errorf("regionkey: KEK source returned a nil KEK") - } - // Zero and unlock the imported KEK the instant this call returns, whatever - // the outcome below. - defer kek.Destroy() - - if version == "" { - return WrappedKey{}, fmt.Errorf("regionkey: KEK source returned an empty key version") - } - - wrapped, err := aeskw.Wrap(kek.Bytes(), cek) - if err != nil { - return WrappedKey{}, fmt.Errorf("regionkey: wrapping CEK: %w", err) - } - return WrappedKey{Version: version, Ciphertext: wrapped}, nil -} - -// Unwrap implements [Provider.Unwrap]. It fetches the region KEK named by -// wrapped.Version for scope and A256KW-unwraps the CEK under it. As in Wrap the -// KEK is locked for the call only and zeroed before returning. A wrong-key or -// tampered wrap surfaces as an error wrapping fee/aeskw.ErrIntegrity; a missing -// version surfaces as [ErrUnknownVersion]. -func (p *SoftwareProvider) Unwrap(ctx context.Context, scope Scope, wrapped WrappedKey) ([]byte, error) { - if p.source == nil { - return nil, fmt.Errorf("regionkey: provider has no KEK source") - } - kek, err := p.source.KEKAt(ctx, scope, wrapped.Version) - if err != nil { - return nil, fmt.Errorf("regionkey: fetching KEK version %q: %w", wrapped.Version, err) - } - if kek == nil { - return nil, fmt.Errorf("regionkey: KEK source returned a nil KEK for version %q", wrapped.Version) - } - defer kek.Destroy() - - cek, err := aeskw.Unwrap(kek.Bytes(), wrapped.Ciphertext) - if err != nil { - return nil, fmt.Errorf("regionkey: unwrapping CEK: %w", err) - } - return cek, nil -} diff --git a/regionkey/static.go b/regionkey/static.go deleted file mode 100644 index b9e273b8..00000000 --- a/regionkey/static.go +++ /dev/null @@ -1,91 +0,0 @@ -package regionkey - -import ( - "context" - "errors" - "fmt" -) - -// KEKLen is the required length in bytes of a region KEK: 32 bytes (256 bits), -// i.e. A256KW. -const KEKLen = 32 - -// StaticKEKSource is an in-memory [KEKSource] holding exactly one region KEK for -// all scopes. It is the v1 "single region key" choice — the region-key -// cardinality decision (FIL-572) is deferred — and the in-memory single-key -// implementation the issue calls for. Swapping it for a Vault-backed or -// multi-key source changes nothing in [SoftwareProvider] or Ingot. -// -// The KEK is kept in its own locked buffer for the lifetime of the source; -// CurrentKEK and KEKAt hand out locked copies so that a caller destroying its -// copy after one operation never touches the retained key. Call [StaticKEKSource.Close] -// at shutdown to zero and unlock the retained key. -type StaticKEKSource struct { - version KeyVersion - kek *KEK -} - -var _ KEKSource = (*StaticKEKSource)(nil) - -// NewStaticKEKSource builds a source holding kek under version. kek must be -// exactly [KEKLen] bytes and version must be non-empty. kek is copied into a -// locked buffer; the caller's slice is not retained and may be zeroed by the -// caller afterward. -func NewStaticKEKSource(version KeyVersion, kek []byte) (*StaticKEKSource, error) { - if version == "" { - return nil, errors.New("regionkey: key version must not be empty") - } - if len(kek) != KEKLen { - return nil, fmt.Errorf("regionkey: region KEK must be %d bytes (A256KW), got %d", KEKLen, len(kek)) - } - locked, err := NewKEK(kek) - if err != nil { - return nil, err - } - return &StaticKEKSource{version: version, kek: locked}, nil -} - -// CurrentKEK implements [KEKSource.CurrentKEK]. Every scope resolves to the one -// retained key, since v1 holds a single region-wide KEK. -func (s *StaticKEKSource) CurrentKEK(ctx context.Context, scope Scope) (KeyVersion, *KEK, error) { - copied, err := s.copyKEK() - if err != nil { - return "", nil, err - } - return s.version, copied, nil -} - -// KEKAt implements [KEKSource.KEKAt]. It returns the retained key for the one -// version it holds and [ErrUnknownVersion] for any other — which is how an -// unwrap of data wrapped by a different region (a different key version) is -// reported, distinct from a same-version wrong-key unwrap that fails the -// A256KW integrity check. -func (s *StaticKEKSource) KEKAt(ctx context.Context, scope Scope, version KeyVersion) (*KEK, error) { - if version != s.version { - // The Provider wrapping this error already names the requested version. - return nil, ErrUnknownVersion - } - return s.copyKEK() -} - -// Close zeroes and unlocks the retained KEK and marks the source unusable; a -// subsequent CurrentKEK or KEKAt returns an error rather than handing out a -// wiped key. It is idempotent and nil-safe (a zero-value source Closes cleanly). -func (s *StaticKEKSource) Close() error { - if s.kek != nil { - s.kek.Destroy() - s.kek = nil - } - return nil -} - -// copyKEK returns a fresh locked KEK holding a copy of the retained key, for a -// caller to use in a single operation and then Destroy. It errors if the source -// has been closed (or was never initialized), so a use-after-Close cannot -// silently wrap under a zeroed key. -func (s *StaticKEKSource) copyKEK() (*KEK, error) { - if s.kek == nil { - return nil, errors.New("regionkey: KEK source is closed") - } - return NewKEK(s.kek.Bytes()) -}