From 747d42f64b2de726b350b0469a54c66ad59fd1e2 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Thu, 12 Nov 2026 16:23:51 +1100 Subject: [PATCH] feat(go): key vendor backends through the encrypt guest (CIP-5311) --- .github/workflows/tests-golang.yml | 12 + AGENTS.md | 6 + Cargo.lock | 1 + languages/golang/encrypt/client.go | 96 +- languages/golang/encrypt/doc.go | 96 +- languages/golang/encrypt/errors.go | 18 +- languages/golang/encrypt/export_test.go | 56 ++ languages/golang/encrypt/guest/Cargo.lock | 101 ++ languages/golang/encrypt/guest/Cargo.toml | 14 +- languages/golang/encrypt/guest/src/abi.rs | 161 ++- languages/golang/encrypt/guest/src/config.rs | 553 ++++++++++- .../golang/encrypt/guest/src/deterministic.rs | 107 +- languages/golang/encrypt/guest/src/host.rs | 69 +- languages/golang/encrypt/guest/src/io.rs | 97 +- languages/golang/encrypt/guest/src/lib.rs | 22 +- languages/golang/encrypt/guest/src/status.rs | 83 +- .../golang/encrypt/guest/src/test_frame.rs | 2 + languages/golang/encrypt/guest/src/vendor.rs | 916 ++++++++++++++++++ languages/golang/encrypt/guest/src/zerokms.rs | 140 +++ languages/golang/encrypt/keyvendor.go | 579 +++++++++++ languages/golang/encrypt/keyvendor_test.go | 666 +++++++++++++ languages/golang/encrypt/options.go | 21 +- languages/golang/encrypt/step.go | 158 ++- languages/golang/encrypt/step_test.go | 62 ++ languages/golang/encrypt/transport.go | 15 +- languages/golang/internal/guest/errors.go | 3 + languages/golang/internal/guest/status.go | 5 +- mise.toml | 16 +- packages/stack-guest-abi/Cargo.toml | 8 + packages/stack-guest-abi/src/lib.rs | 2 + packages/stack-guest-abi/src/status.rs | 9 +- packages/stack-guest-abi/src/step.rs | 116 ++- .../stack-guest-abi/src/vendor_transport.rs | 209 ++++ 33 files changed, 4284 insertions(+), 135 deletions(-) create mode 100644 languages/golang/encrypt/guest/src/vendor.rs create mode 100644 languages/golang/encrypt/guest/src/zerokms.rs create mode 100644 languages/golang/encrypt/keyvendor.go create mode 100644 languages/golang/encrypt/keyvendor_test.go create mode 100644 packages/stack-guest-abi/src/vendor_transport.rs diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index 881f4447ed..3faaf0c8d8 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -41,6 +41,12 @@ on: - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** + # The encrypt guest links every key vendor backend (CIP-5311). + - packages/stack-kms-vendor/** + - packages/stack-kms-aws/** + - packages/stack-kms-vault/** + - packages/stack-kms-gcp/** + - packages/stack-kms-azure/** # The eql guest build links eql-bindings. - packages/eql/crates/eql-bindings/** - packages/eql/crates/eql-domains/** @@ -84,6 +90,12 @@ on: - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** + # The encrypt guest links every key vendor backend (CIP-5311). + - packages/stack-kms-vendor/** + - packages/stack-kms-aws/** + - packages/stack-kms-vault/** + - packages/stack-kms-gcp/** + - packages/stack-kms-azure/** # The eql guest build links eql-bindings. - packages/eql/crates/eql-bindings/** - packages/eql/crates/eql-domains/** diff --git a/AGENTS.md b/AGENTS.md index be78df259f..3960d93033 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -759,6 +759,12 @@ exact pin would stop the suite sharing one `cts-common` with it. `mutants.yml`, `fuzz.yml`, `miri.yml` and `udeps.yml`. `scripts/__tests__/crates-ci.test.mjs` fails when a mise task in the root `mise.toml` or the ten crate `tasks.toml` files loses its last CI caller. +- **The encrypt guest links the key vendor crates.** It builds + `stack-kms-vendor` and the four vendor crates in, `stack-kms-gcp` + without `service-account`, so the guest holds no RSA code, and sends + vendor requests through `stack-guest-abi`'s `vendor` feature. Keep the + short key vendor matrix in the Go `encrypt` package docs in step with + the `stack-kms-vendor` crate docs. - **Auth refresh cancellation soak:** `tests-auth-refresh.yml` runs a short, seeded, virtual-time soak on every PR and the long run on auth changes and Version Packages PRs. `release-plz.yml` also requires the long run before diff --git a/Cargo.lock b/Cargo.lock index 2422905130..c1bb7832a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3582,6 +3582,7 @@ dependencies = [ "miette", "proptest", "serde_json", + "stack-kms-vendor", "thiserror 1.0.69", "vitaminc-aead 0.9.0", "vitaminc-aead-frame", diff --git a/languages/golang/encrypt/client.go b/languages/golang/encrypt/client.go index 042a7d09c5..fb195875ee 100644 --- a/languages/golang/encrypt/client.go +++ b/languages/golang/encrypt/client.go @@ -16,11 +16,12 @@ import ( ) // Client is one wasm instance holding one ZeroKMS client: its key, its -// default keyset, and the keysets it has loaded since. It is safe for +// default keyset, and the keysets it has loaded since — and, when it is +// given one, a key vendor backend with its keyset records. It is safe for // concurrent use; the guest runs one call's step at a time, because a wasm // instance is single-threaded, but never waits on the network inside one: -// a ZeroKMS request is performed between steps, with the client free for -// other callers (see step.go). Close it when done, as with any resource. +// a ZeroKMS or key vendor request is performed between steps, with the +// client free for other callers (see step.go). Close it when done, as with any resource. // // A call whose context ends fails with the context's error, and only that // call: one request's cancellation or deadline never closes the client for @@ -86,17 +87,38 @@ type Client struct { // With no options it is a working client: the credentials are // [AutoCredentials], and every other setting has a default. Each // [ClientOption] changes one. +// +// With [WithKeyVendor] and [WithKeysetRecords] the client also holds a key +// vendor backend. NewClient parses and checks the records but makes no +// vendor call: a record key is unwrapped on the first use of its keyset. func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) { var cfg clientOptions for _, opt := range opts { opt(&cfg) } + // A StaticKey is consumed like the client key: wiped whatever the + // outcome, a refused config and a replaced option included. + defer func() { + wipeVendorKey(cfg.keyVendor) + for _, v := range cfg.supersededVendors { + wipeVendorKey(v) + } + }() rt := cfg.transport if rt == nil { rt = http.DefaultTransport } + // A key vendor backend is an alternative to ZeroKMS: with one and no + // WithCredentials, the client has no ZeroKMS, and no ZeroKMS + // credentials are looked for. WithCredentials(AutoCredentials()) gives + // both. + vendorOnly := cfg.keyVendor != nil && cfg.credentials == nil && len(cfg.superseded) == 0 creds := cfg.credentials - if creds == nil { + switch { + case creds != nil: + case vendorOnly: + creds = noZeroKMS{} + default: creds = AutoCredentials() } // Credentials a later WithCredentials replaced are never resolved, but @@ -114,10 +136,21 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) // for. A refused config still consumes an explicit key, as // WithCredentials promises; any other Credentials has not been asked // yet, so holds nothing of this client's. - zerokmsURL, err := zerokmsEndpoint(cfg.zerokmsURL) + var zerokmsURL string + if !vendorOnly { + // A client with no ZeroKMS has no ZeroKMS endpoint to pin. + zerokmsURL, err = zerokmsEndpoint(cfg.zerokmsURL) + } if err == nil && cfg.keysetCacheSize < 0 { err = fmt.Errorf("%w: WithKeysetCacheSize must not be negative", ErrEncoding) } + var vendor *vendorSpec + if err == nil { + vendor, err = keyVendorConfig(cfg.keyVendor, cfg.keysetRecords) + } + if err == nil { + err = checkVendorOnlyOptions(&cfg, vendorOnly) + } if explicit, ok := creds.(*explicitCredentials); ok && err == nil && explicit.token == nil { // Knowable from the credentials as they were built: the one place // a missing token source is decided. @@ -173,6 +206,11 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) clientKey: resolved.ClientKey, zerokmsURL: zerokmsURL, keysetCacheSize: cfg.keysetCacheSize, + vendor: vendor, + keysetRecords: cfg.keysetRecords, + vendorOnly: vendorOnly, + defaultKeyset: cfg.defaultKeyset, + rootKeyTTL: cfg.rootKeyTTL, }) if err != nil { return nil, err @@ -184,7 +222,7 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) // whatever the init's outcome. resolved.ClientKey.Wipe() - t := &transport{rt: rt, token: resolved.Token} + t := &transport{rt: rt, token: resolved.Token, vendor: vendor} inst, err := newInstance(ctx, wasm, t, guest.PolicyFor(cfg.requireLockedMemory)) if err != nil { return nil, err @@ -304,11 +342,23 @@ type initConfig struct { clientKey *ClientKey zerokmsURL string keysetCacheSize int + // vendor and keysetRecords are the key vendor backend, nil and empty + // when the client has none. + vendor *vendorSpec + keysetRecords []string + // vendorOnly is a client with the key vendor backend and no ZeroKMS: + // the config then has no client id, client key or ZeroKMS endpoint. + vendorOnly bool + defaultKeyset KeysetSelector + rootKeyTTL *time.Duration } // encodeConfig renders the se_cipher_init object. The result holds the // client key; the caller wipes it, and the key it was read from. func encodeConfig(cfg initConfig) ([]byte, error) { + if cfg.vendorOnly { + return vcffi.Marshal(cfg.vendorFields()) + } if cfg.clientID == "" || cfg.clientKey.IsZero() { return nil, errors.New("encrypt: the credentials' client id and client key are required") } @@ -330,9 +380,30 @@ func encodeConfig(cfg initConfig) ([]byte, error) { if cfg.keysetCacheSize > 0 { fields = append(fields, vcvalue.Field{Key: "keyset_cache_size", Value: strconv.Itoa(cfg.keysetCacheSize)}) } + fields = append(fields, cfg.vendorFields()...) return vcffi.Marshal(fields) } +// vendorFields renders the key vendor backend's config keys: none without +// one. The result can hold a StaticKey's key; the caller wipes what it +// encodes. +func (cfg initConfig) vendorFields() vcvalue.Object { + if cfg.vendor == nil { + return nil + } + fields := cfg.vendor.configFields(cfg.keysetRecords) + if cfg.keysetCacheSize > 0 && cfg.vendorOnly { + fields = append(fields, vcvalue.Field{Key: "keyset_cache_size", Value: strconv.Itoa(cfg.keysetCacheSize)}) + } + if cfg.defaultKeyset != nil { + fields = append(fields, vcvalue.Field{Key: "default_keyset", Value: cfg.defaultKeyset.selector()}) + } + if cfg.rootKeyTTL != nil { + fields = append(fields, vcvalue.Field{Key: "root_key_ttl_ms", Value: strconv.FormatInt(cfg.rootKeyTTL.Milliseconds(), 10)}) + } + return fields +} + // Close shuts the guest down — the client key and every loaded index key // are wiped inside the instance — and releases the runtime and the // guest's memory, which is wiped on the way out. Idempotent. Every call @@ -375,7 +446,9 @@ func (c *Client) Keyset(sel KeysetSelector) *Cipher { // DefaultKeyset binds the client to its default keyset — the one a ZeroKMS // administrator set for this client, which is what naming no keyset -// resolves to: Rust's StackCipher::default_keyset. Loaded at NewClient, so +// resolves to: Rust's StackCipher::default_keyset. A client with a key +// vendor backend and no ZeroKMS has a vendor keyset as its default: its only +// keyset record, or the one [WithDefaultKeyset] names. Loaded at NewClient, so // using it never touches ZeroKMS. There is no way to redefine it from here; // which keyset is the default is the server's to say. Any other keyset is // [Client.Keyset]. @@ -466,6 +539,8 @@ func (c *Client) drive(ctx context.Context, kms bool, f stepFunc) ([]byte, error step, resumed := f, false var handle uint64 var responses []byte + var op operation + defer op.end() for { out, io, err := c.step(ctx, token, tokenErr, step) if resumed { @@ -483,6 +558,11 @@ func (c *Client) drive(ctx context.Context, kms bool, f stepFunc) ([]byte, error return nil, ctx.Err() } if io == nil { + // The guest reports a failed credential request as a transport + // failure and no more; the credential source said why. + if err != nil && op.credErr != nil { + err = fmt.Errorf("%w (key vendor credential source: %w)", err, op.credErr) + } return settle(ctx, out, err) } // A caller that gave up while the guest ran sends no request. @@ -491,7 +571,7 @@ func (c *Client) drive(ctx context.Context, kms bool, f stepFunc) ([]byte, error c.abort(io.handle) return nil, fmt.Errorf("%w: %w", err, ErrTransport) } - responses, err = c.transport.performStep(ctx, io) + responses, err = c.transport.performStep(ctx, io, &op) if err != nil { c.abort(io.handle) return nil, err diff --git a/languages/golang/encrypt/doc.go b/languages/golang/encrypt/doc.go index ea39184435..2f8fd5e47b 100644 --- a/languages/golang/encrypt/doc.go +++ b/languages/golang/encrypt/doc.go @@ -314,10 +314,17 @@ // // The guest imports exactly one host function, a bearer-token fetch, served // by the credentials' auth strategy. It never performs a request itself: an -// operation that needs ZeroKMS pauses and hands the request to the client, -// which sends it with any [net/http.RoundTripper] and resumes the operation -// with the response. What crosses per ZeroKMS call is what would -// cross TLS anyway; derived key material never leaves the guest. Under +// operation that needs ZeroKMS or the key vendor backend pauses and hands +// the request to the client, which sends it with any +// [net/http.RoundTripper] and resumes the operation with the response. A +// key vendor backend's credential, from the source given with +// [WithKeyVendor], and a retry's backoff are handed to the client the same +// way. What crosses per ZeroKMS call is what would cross TLS anyway, and +// keys derived from a ZeroKMS response never leave the guest: a response +// alone is not usable. A key vendor backend is different: the host does the +// TLS, so the host and the RoundTripper see each vendor request and +// response in plaintext, and those carry unwrapped keys (see [KeyVendor]). +// Under // [AutoCredentials] and [OIDCFederation] the same RoundTripper also carries // the authentication requests to CTS, so one scoped to the ZeroKMS host alone // is not enough. Under [NewCredentials] those requests go through the store @@ -340,6 +347,87 @@ // resolved fail NewClient with [ErrNoCredentials]; credentials that resolve // but do not work fail it too, at the one ZeroKMS round trip it makes. // +// # Key vendor backends +// +// A key vendor backend is an alternative to ZeroKMS: [AWSKMS], +// [GoogleCloudKMS], [AzureKeyVault], [VaultTransit] or [StaticKey]. Give it +// with [WithKeyVendor], and its keyset records (the "stack-keyset:…" texts) +// with [WithKeysetRecords]: +// +// client, err := encrypt.NewClient(ctx, +// encrypt.WithKeyVendor(encrypt.AWSKMS{Region: "us-east-1", Credentials: creds}), +// encrypt.WithKeysetRecords(os.Getenv("USERS_KEYSET_RECORD"))) +// +// With no [WithCredentials] the client has no ZeroKMS and needs no +// CipherStash credentials. Its default keyset is its only keyset record, or +// the one [WithDefaultKeyset] names, and a call that needs ZeroKMS fails +// with [ErrNoZeroKMS]. With WithCredentials too, the client holds both: a +// keyset in a record goes to the vendor, and every other keyset, the default +// included, goes to ZeroKMS. Select a vendor keyset by its record's name or +// id with [Client.Keyset]. A value sealed under one backend never opens +// under the other. +// +// The guest builds and signs every vendor request. The host supplies the +// credentials, per request: AWS credentials, a Google OAuth access token, an +// Azure access token for the scope the guest names, or a Vault token. Fill +// them from the vendor's own Go credential chain through +// [AWSCredentialsFunc], [AccessTokenFunc] or [OAuth2AccessTokens], which +// give short-lived credentials. [AWSCredentialsFromEnv] and a fixed Vault +// token can give long-lived ones, and those cross to the guest on each +// request too. This package imports no vendor SDK. +// +// The host sends the vendor requests and does their TLS. So the host, and +// the RoundTripper given with [WithTransport], see in plaintext the root key +// that a wrap request sends and that an unwrap response returns. Unlike a +// ZeroKMS response, such a key is usable alone. The client wipes the request +// and response bodies it holds, but not the buffers of net/http, TLS or the +// RoundTripper. Give WithTransport only a RoundTripper you trust with key +// material. +// +// A keyset record holds the keyset's root key, wrapped by the vendor key. +// The guest derives each data key and the index key from the root key +// (HKDF-SHA256), with no vendor call. The database holds no usable key +// material. +// +// key lifetime root key: cached in the guest for 5 minutes +// (WithRootKeyTTL); index key: kept until the +// keyset leaves the client's keyset cache; data +// keys: derived per call +// vendor calls per 0, or 1 per keyset when the root key is not +// call, per keyset cached +// the vendor can the root key unwrap: a refusal takes effect when +// refuse the cached root key expires +// +// After the vendor refuses a keyset, the client can no longer encrypt or +// decrypt its values once the cached root key expires. It can still derive +// search terms for that keyset while the keyset's index key stays in the +// client's keyset cache, as it can for a ZeroKMS keyset. The first use of a +// Vault Transit key or an Azure key costs one more call, a key read. +// +// The associated data (the keyset id, the vendor and the key mode) is bound +// to every wrapped root key. AWS KMS binds it as the encryption context, +// which CloudTrail logs and key policies can test. Google Cloud KMS binds it +// as additional authenticated data. Vault Transit and Azure Key Vault wrap +// a key with a digest of it, which the guest checks after the unwrap; a +// derived Transit key also gets it as its derivation context. Azure does not +// see the binding. Nothing outside the process can refuse or audit a use of +// [StaticKey]. +// +// A rewrap of a keyset record (stash keyset rewrap) wraps the root key under +// another vendor key. It does not re-key stored values, and every value +// still opens. The stack-kms-vendor crate documentation has the full +// capability matrix: credentials, rotation and known limits of each backend. +// +// A vendor keyset refuses ZeroKMS-only features (a lock context, a +// decryption policy) with an error; it never ignores one. A vendor failure +// reads as the ZeroKMS one of the same kind: [ErrForbidden] for a refused +// request or bad credentials, [ErrNotFound] for a missing key, [ErrTransport] +// for an unreachable vendor, a throttle that outlasted the retries or a +// failed credential source (whose own error errors.Is also matches), and +// [ErrAuthentication] for a wrapped root key that does not open. The +// [*Diagnostic]'s code is "stack_kms::backend", and its "vendor" field names +// the backend. +// // # Errors // // The generator and the compiler find a mistake in a declaration, so no call diff --git a/languages/golang/encrypt/errors.go b/languages/golang/encrypt/errors.go index 15b9dc9313..5e2090307d 100644 --- a/languages/golang/encrypt/errors.go +++ b/languages/golang/encrypt/errors.go @@ -17,7 +17,8 @@ import ( // two. var ( // ErrAuthentication is an AEAD open failure: a tampered ciphertext, a - // wrong element derivation, or a wrong AAD that reached the AEAD. Against + // wrong element derivation, or a wrong AAD that reached the AEAD. A key + // vendor backend reports a wrapped key that does not unwrap as this too. Against // ZeroKMS a wrong AAD is usually refused earlier as ErrForbidden, because // every data key is bound to its context. ErrAuthentication = guest.ErrAuthentication @@ -33,15 +34,21 @@ var ( ErrUnauthorized = guest.ErrUnauthorized // ErrForbidden is ZeroKMS refusing the request (HTTP 403): the token is // valid but not permitted, or a data key's bound context did not match - // the one presented — the production form of a wrong-AAD open. + // the one presented — the production form of a wrong-AAD open. From a + // key vendor backend: the vendor refused the request, or the guest + // refused the host's credential (expired, or the wrong shape). ErrForbidden = guest.ErrForbidden // ErrNotFound is ZeroKMS reporting a missing resource (HTTP 404): an - // unknown keyset name or id, or a data key that does not exist. + // unknown keyset name or id, or a data key that does not exist. From a + // key vendor backend: the vendor key does not exist. ErrNotFound = guest.ErrNotFound // ErrConflict is ZeroKMS reporting a resource conflict (HTTP 409). ErrConflict = guest.ErrConflict // ErrTransport is a failure to reach ZeroKMS or to read its response: // the transport returned an error, or the endpoint could not be resolved. + // From a key vendor backend also: the vendor is unavailable, it went on + // throttling past the retries, or the key vendor credential source + // failed (its error is attached, so errors.Is matches it too). ErrTransport = guest.ErrTransport // ErrKMS is any other ZeroKMS failure: an unparseable response, invalid // key material, or an unclassified server error. @@ -58,6 +65,11 @@ var ( // [Cipher.Context]. Refused before any key is retrieved; a row stored // under another tenant is a mismatch, never a decrypted value. ErrContextMismatch = guest.ErrContextMismatch + // ErrNoZeroKMS is a client that has a key vendor backend and no ZeroKMS + // asked for a ZeroKMS keyset: a keyset none of its keyset records names, + // or a value a ZeroKMS keyset sealed. Refused before any request. Give + // the keyset's record, or ZeroKMS credentials with WithCredentials. + ErrNoZeroKMS = guest.ErrNoZeroKMS // ErrMemoryLock is guest memory that could not be locked in RAM (or, // on Linux, excluded from core dumps). NewClient returns it when // WithRequireLockedMemory is given, and so does any later call under diff --git a/languages/golang/encrypt/export_test.go b/languages/golang/encrypt/export_test.go index 53dc819444..0c93f5d91a 100644 --- a/languages/golang/encrypt/export_test.go +++ b/languages/golang/encrypt/export_test.go @@ -11,6 +11,7 @@ import ( "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/cipherstash/stack/languages/golang/internal/record" "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" "github.com/tetratelabs/wazero/api" ) @@ -135,6 +136,61 @@ func deterministicClient(ctx context.Context, wasm []byte, seed [32]byte) (*Clie return c, nil } +// KeyVendorWire is the KeyVendor byte and the key_vendor name the client +// sends to the guest for v. +func KeyVendorWire(v KeyVendor) (byte, string, error) { + spec, err := v.keyVendor() + if err != nil { + return 0, "", err + } + return byte(spec.vendor), spec.vendor.String(), nil +} + +// KeyVendorNameCount is the number of vendors in the Go table of names. +func KeyVendorNameCount() int { return len(keyVendorNames) } + +// NewDeterministicVendorClient is NewDeterministicClient with a key vendor +// backend beside the seeded ZeroKMS stand-in: the deterministic build's +// object config, {"seed", key_vendor, keyset_records, ...}. Vendor requests +// go to rt, and their credentials come from the backend's source through +// the real step loop. The StaticKey, if any, is wiped as NewClient wipes +// it. +func NewDeterministicVendorClient(ctx context.Context, seed [32]byte, rt http.RoundTripper, v KeyVendor, records ...string) (*Client, error) { + defer wipeVendorKey(v) + wasm, err := os.ReadFile(deterministicGuestPath) + if err != nil { + return nil, ErrDeterministicGuestNotBuilt + } + spec, err := keyVendorConfig(v, records) + if err != nil { + return nil, err + } + fields := vcvalue.Object{{Key: "seed", Value: seed[:]}} + if spec != nil { + fields = append(fields, spec.configFields(records)...) + } + encoded, err := vcffi.Marshal(fields) + if err != nil { + return nil, err + } + defer wipe(encoded) + tr := &transport{rt: rt, token: noToken{}, vendor: spec} + inst, err := newInstance(ctx, wasm, tr, guest.BestEffort) + if err != nil { + return nil, err + } + c := newClient(inst, tr) + out, err := c.call(ctx, func(ctx context.Context, inst *instance) ([]byte, error) { + return inst.call(ctx, inst.cipherInit, buf(encoded)) + }) + if err != nil { + _ = c.Close() + return nil, fmt.Errorf("encrypt: deterministic cipher init: %w", err) + } + copy(c.def[:], out) + return c, nil +} + // RawClient is a guest that was never given a client key: every well-formed // operation is ErrState, every malformed one ErrEncoding, and the checker // exports work. diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index 9c2cc2d59b..746d84cdc5 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -466,6 +466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -979,6 +980,15 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.13.0" @@ -2172,6 +2182,11 @@ dependencies = [ "stack-encrypt", "stack-guest-abi", "stack-kms", + "stack-kms-aws", + "stack-kms-azure", + "stack-kms-gcp", + "stack-kms-vault", + "stack-kms-vendor", "uuid", "zeroize", "zerokms-protocol", @@ -2183,6 +2198,7 @@ version = "0.0.0" dependencies = [ "miette", "serde_json", + "stack-kms-vendor", "thiserror 1.0.69", "vitaminc-aead 0.9.0", "vitaminc-aead-frame", @@ -2218,6 +2234,91 @@ dependencies = [ "zerokms-protocol", ] +[[package]] +name = "stack-kms-aws" +version = "0.1.0" +dependencies = [ + "base64ct", + "hmac", + "serde", + "serde_json", + "sha2 0.11.0", + "stack-kms", + "stack-kms-vendor", + "thiserror 1.0.69", + "url", + "vitaminc 0.9.0", + "web-time", + "zeroize", +] + +[[package]] +name = "stack-kms-azure" +version = "0.1.0" +dependencies = [ + "base64ct", + "serde", + "serde_json", + "stack-kms", + "stack-kms-vendor", + "thiserror 1.0.69", + "url", + "vitaminc 0.9.0", + "web-time", + "zeroize", +] + +[[package]] +name = "stack-kms-gcp" +version = "0.1.0" +dependencies = [ + "base64ct", + "serde", + "serde_json", + "stack-kms", + "stack-kms-vendor", + "thiserror 1.0.69", + "url", + "vitaminc 0.9.0", + "web-time", + "zeroize", +] + +[[package]] +name = "stack-kms-vault" +version = "0.1.0" +dependencies = [ + "base64ct", + "serde", + "serde_json", + "stack-kms", + "stack-kms-vendor", + "thiserror 1.0.69", + "url", + "vitaminc 0.9.0", + "zeroize", +] + +[[package]] +name = "stack-kms-vendor" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "base64ct", + "futures", + "hkdf", + "miette", + "serde_json", + "sha2 0.11.0", + "stack-kms", + "subtle", + "thiserror 1.0.69", + "uuid", + "vitaminc 0.9.0", + "web-time", + "zeroize", +] + [[package]] name = "stack-profile" version = "0.44.0" diff --git a/languages/golang/encrypt/guest/Cargo.toml b/languages/golang/encrypt/guest/Cargo.toml index 4df0b70ba7..15edaec71b 100644 --- a/languages/golang/encrypt/guest/Cargo.toml +++ b/languages/golang/encrypt/guest/Cargo.toml @@ -53,8 +53,20 @@ stack-encrypt = { path = "../../../../packages/stack-encrypt", default-features # buffer registry (and with them the `se_alloc`/`se_dealloc` exports), the # packed-result helpers, the status table and the step loop. # This crate defines only the exports that are its own. -stack-guest-abi = { path = "../../../../packages/stack-guest-abi" } +stack-guest-abi = { path = "../../../../packages/stack-guest-abi", features = ["vendor"] } stack-kms = { path = "../../../../packages/stack-kms", default-features = false } +# The key vendor backends (ADR-0013, ADR-0014, CIP-5311): the seam, the +# keyset records and the static key backend, and one hand-written client for +# each vendor. Default features off: no reqwest and no native credential +# chains. HTTP goes through `transport_send` (`HostVendorTransport`) and the +# short-lived credentials come from the host's `vendor_credential_get` +# import. `stack-kms-gcp` without `service-account` has no RSA code and no +# `rsa` dependency: the guest never signs with a service account key. +stack-kms-vendor = { path = "../../../../packages/stack-kms-vendor", default-features = false } +stack-kms-aws = { path = "../../../../packages/stack-kms-aws", default-features = false } +stack-kms-vault = { path = "../../../../packages/stack-kms-vault", default-features = false } +stack-kms-gcp = { path = "../../../../packages/stack-kms-gcp", default-features = false } +stack-kms-azure = { path = "../../../../packages/stack-kms-azure", default-features = false } # The EQL types, for the `eql` build only. By path, like every other # eql-bindings dependency in the repository: the SQL that stores a payload # and the Rust that emits it must agree (scripts/lint-no-eql-registry-pins.mjs). diff --git a/languages/golang/encrypt/guest/src/abi.rs b/languages/golang/encrypt/guest/src/abi.rs index 9abc6e1648..6a2bef0892 100644 --- a/languages/golang/encrypt/guest/src/abi.rs +++ b/languages/golang/encrypt/guest/src/abi.rs @@ -106,14 +106,28 @@ use crate::status::STATUS_INTERNAL; #[cfg(not(feature = "deterministic-kms"))] use stack_encrypt::ErrorPayload; -/// The key source the instance's cipher runs over: the host-transport -/// ZeroKMS client with host-supplied tokens — or, in the `deterministic-kms` -/// test build, the seeded source the record fixture was sealed under. +/// The key source the instance's cipher runs over: ZeroKMS and at most one +/// key vendor backend, routed by keyset ([`stack_kms_vendor::KeyRouter`]). +/// ZeroKMS is the host-transport client with host-supplied tokens — or, in +/// the `deterministic-kms` test build, the seeded source the record fixture +/// was sealed under. The vendor is [`GuestVendor`], over the step loop; with +/// none configured the router has no vendor and every request goes to +/// ZeroKMS. #[cfg(not(feature = "deterministic-kms"))] -type GuestKms = - stack_kms::StackKms; +type GuestZeroKms = crate::zerokms::MaybeZeroKms< + stack_kms::StackKms, +>; #[cfg(feature = "deterministic-kms")] -type GuestKms = crate::deterministic::DeterministicSource; +type GuestZeroKms = crate::zerokms::MaybeZeroKms; + +/// The instance's key vendor backend: its requests, credentials and +/// backoff waits go to the host through the step loop. +type GuestVendor = crate::vendor::GuestVendor< + stack_guest_abi::vendor_transport::StepVendorTransport, + crate::host::StepCredentialImport, +>; + +type GuestKms = stack_kms_vendor::KeyRouter; /// The instance's cipher: `stack-encrypt` over [`GuestKms`]. type GuestCipher = StackCipher; @@ -205,7 +219,7 @@ pub unsafe extern "C" fn se_cipher_init(cfg_ptr: *mut u8, cfg_len: u32) -> u64 { #[cfg(not(feature = "deterministic-kms"))] use crate::config::{parse_config as parse_init, CipherConfig as InitConfig}; #[cfg(feature = "deterministic-kms")] -use crate::deterministic::Seed as InitConfig; +use crate::deterministic::SeedConfig as InitConfig; #[cfg(feature = "deterministic-kms")] fn parse_init(bytes: &[u8]) -> Result { InitConfig::parse(bytes) @@ -215,40 +229,60 @@ fn parse_init(bytes: &[u8]) -> Result { fn cipher_init(config: InitConfig) -> Result, u32> { use crate::host::{HostTokenStrategy, WasiHostConnection}; use crate::status::STATUS_KMS_TRANSPORT; + use crate::zerokms::MaybeZeroKms; use stack_kms::{ClientOpts, StackKms}; if init_refused() { return Err(init_out_of_order()); } - // One request at a time: a step hands the host one request, and the - // first version of the step loop keeps it that way. Raising it later - // changes no ABI: a step's request list can hold several. - // - // `max_keys_per_req` stays at the client default (500). That is what - // bounds "one ZeroKMS call": a batch is assembled once, then - // `Client::send_chunked` splits it into sequential requests of at most - // that many keys — so a 1200-leaf record batch is three calls, not one. - // Raising it here would trade a documented, server-friendly request size - // for a claim the server need not honour, so the bound is kept and the - // docs say 500 rather than "one". - let opts = ClientOpts::new(config.endpoint) - .with_max_concurrent_reqs(1) - .map_err(|e| last_error::record(&e, e.payload(), STATUS_INTERNAL))?; - let kms = StackKms::::connect( - opts, - HostTokenStrategy, - config.client_key, - ) - .map_err(|e| { - last_error::record_with( - &e, - e.payload(), - &crate::status::describe, - STATUS_KMS_TRANSPORT, - ) - })?; - + let vendor = config.vendor.map(build_vendor).transpose()?; + let kms = match (config.client_key, vendor) { + // A key vendor backend and no ZeroKMS (CIP-5311): no ZeroKMS client + // is built, no token is asked for, and the default keyset is the + // vendor's. + (None, Some(vendor)) => { + stack_kms_vendor::KeyRouter::new(MaybeZeroKms::Absent, vendor).with_vendor_default() + } + // `parse_config` asks for the ZeroKMS keys when there is no key + // vendor backend, so this config does not parse. Refuse it here + // too, rather than build a client with no key source. + (None, None) => { + return Err(malformed( + "the config has no ZeroKMS client key and no key vendor backend", + )) + } + (Some(client_key), vendor) => { + // One request at a time: a step hands the host one request, and the + // first version of the step loop keeps it that way. Raising it later + // changes no ABI: a step's request list can hold several. + // + // `max_keys_per_req` stays at the client default (500). That is what + // bounds "one ZeroKMS call": a batch is assembled once, then + // `Client::send_chunked` splits it into sequential requests of at most + // that many keys — so a 1200-leaf record batch is three calls, not one. + // Raising it here would trade a documented, server-friendly request size + // for a claim the server need not honour, so the bound is kept and the + // docs say 500 rather than "one". + let opts = ClientOpts::new(config.endpoint) + .with_max_concurrent_reqs(1) + .map_err(|e| last_error::record(&e, e.payload(), STATUS_INTERNAL))?; + let zerokms = StackKms::::connect( + opts, + HostTokenStrategy, + client_key, + ) + .map_err(|e| { + last_error::record_with( + &e, + e.payload(), + &crate::status::describe, + STATUS_KMS_TRANSPORT, + ) + })?; + router(MaybeZeroKms::Present(zerokms), vendor) + } + }; let mut builder = StackCipher::builder().kms(kms); if let Some(size) = config.keyset_cache_size { builder = builder.keyset_cache_size(size); @@ -266,15 +300,31 @@ fn cipher_init(config: InitConfig) -> Result, u32> { } /// The `deterministic-kms` build's init: the config buffer is the -/// deterministic source's 32-byte seed and nothing else — no client key, no -/// endpoint, no token, no network. A test artefact, never shipped; see +/// deterministic source's 32-byte seed, or an object of the seed and the key +/// vendor backend keys — no client key, no endpoint, no token, and no +/// ZeroKMS network. A test artefact, never shipped; see /// [`crate::deterministic`]. #[cfg(feature = "deterministic-kms")] -fn cipher_init(seed: InitConfig) -> Result, u32> { +fn cipher_init(config: InitConfig) -> Result, u32> { if init_refused() { return Err(init_out_of_order()); } - let builder = StackCipher::builder().kms(seed.source()); + // The seeded source is this build's ZeroKMS, so a default keyset of the + // vendor's does not apply here. + if config + .vendor + .as_ref() + .is_some_and(|v| v.default_keyset.is_some()) + { + return Err(malformed( + "the deterministic build's default keyset is the seeded source's", + )); + } + let kms = router( + crate::zerokms::MaybeZeroKms::Present(config.seed.source()), + config.vendor.map(build_vendor).transpose()?, + ); + let builder = StackCipher::builder().kms(kms); let initialising = Initialising::begin(); run(Box::pin(async move { let _initialising = initialising; @@ -312,6 +362,39 @@ impl Drop for Initialising { } } +/// The key source of a client with ZeroKMS: ZeroKMS and the key vendor +/// backend, or ZeroKMS alone when none is configured. +fn router( + zerokms: GuestZeroKms, + vendor: Option>, +) -> GuestKms { + match vendor { + Some(vendor) => stack_kms_vendor::KeyRouter::new(zerokms, vendor), + None => stack_kms_vendor::KeyRouter::zerokms_only(zerokms), + } +} + +/// Build the configured key vendor backend over the step loop: its requests, +/// credentials and backoff waits all pause the operation for the host (no +/// vendor call here: records are parsed and checked, keys are unwrapped on +/// first use). A setting the vendor client refuses is +/// `STATUS_ENCODING` naming its config key; a refused keyset record keeps +/// its error for `se_last_error` ([`crate::status::fail_record`]). +fn build_vendor( + config: crate::vendor::VendorConfig, +) -> Result, u32> { + use crate::vendor::BuildError; + crate::vendor::build( + config, + stack_guest_abi::vendor_transport::StepVendorTransport, + crate::host::StepCredentialImport, + ) + .map_err(|e| match e { + BuildError::Setting(key) => malformed(format!("the config's {key} is not valid")), + BuildError::Record(record) => crate::status::fail_record(&record), + }) +} + /// `se_cipher_init` called a second time, while one is paused, or after /// `se_shutdown`. fn init_out_of_order() -> u32 { diff --git a/languages/golang/encrypt/guest/src/config.rs b/languages/golang/encrypt/guest/src/config.rs index 44f77c2187..9d857ddd20 100644 --- a/languages/golang/encrypt/guest/src/config.rs +++ b/languages/golang/encrypt/guest/src/config.rs @@ -11,6 +11,34 @@ //! | `zerokms_url` | no | pins the ZeroKMS endpoint at init; when absent the endpoint is resolved from the access token's `services` claim on first use | //! | `keyset_cache_size` | no | how many keysets beyond the default the cipher keeps loaded (a positive decimal integer; the crate default, 1024, when absent). See `StackCipherBuilder::keyset_cache_size` | //! +//! A key vendor backend (ADR-0013, CIP-5311) is configured with the keys +//! below. They are all absent for a ZeroKMS-only client, which is then +//! unchanged. +//! +//! | key | when | meaning | +//! |-------------------|------|---------| +//! | `key_vendor` | with any key below | the backend: `aws-kms`, `vault-transit`, `gcp-kms`, `azure-key-vault` or `static` (`KeyVendor::name`) | +//! | `keyset_records` | with `key_vendor`, required | an array of one or more keyset record texts (`stack-keyset:…`), all of that backend | +//! | `aws_region` | `aws-kms`, required | the AWS region | +//! | `aws_endpoint` | `aws-kms` | overrides the regional AWS KMS endpoint | +//! | `vault_address` | `vault-transit`, required | the Vault address | +//! | `vault_namespace` | `vault-transit` | the Vault namespace | +//! | `vault_mount` | `vault-transit` | the Transit mount (default `transit`) | +//! | `gcp_endpoint` | `gcp-kms` | overrides the Cloud KMS endpoint | +//! | `static_key_ref` | `static`, required | the static key's reference, as the records name it | +//! | `static_key` | `static`, required | the static key: 32 bytes (a bytes value, not a string) | +//! | `default_keyset` | no ZeroKMS only | the default keyset, `{"id": 16 bytes}` or `{"name": …}`; else the only record | +//! | `root_key_ttl_ms` | any | how long a keyset's root key stays unwrapped, in milliseconds (`0` keeps none; default 5 minutes) | +//! +//! With a key vendor backend and none of `client_id`, `client_key` and +//! `zerokms_url`, the client has no ZeroKMS: the default keyset is the +//! vendor's, and a request for any other keyset is `STATUS_NO_ZEROKMS`. +//! +//! A vendor key for another backend than `key_vendor`, or with no +//! `key_vendor`, is refused. The vendor's credentials are never in the +//! config: the guest asks the host for them, through the step loop, before +//! each vendor request (see [`crate::vendor`]). +//! //! There is no config key for a keyset. `{"default"}` means the default a //! ZeroKMS administrator set for this client, and a client does not get to //! redefine it — the same reason `StackCipherBuilder::keyset` was removed. @@ -31,18 +59,53 @@ //! that value. use std::num::NonZeroUsize; +use std::time::Duration; use serde::{Deserialize, Deserializer}; use stack_encrypt::dynamic::frame::serde::from_slice; use stack_guest_abi::last_error::malformed; -use stack_kms::{ClientKey, ZeroKmsEndpoint}; +use stack_kms::{ClientKey, KeyVendor, ZeroKmsEndpoint}; +use stack_kms_vendor::KeyBytes; use uuid::Uuid; +use zeroize::Zeroize; + +use crate::vendor::{DefaultKeyset, VendorConfig, VendorSettings}; + +/// The [`VendorWire`] of a wire struct that declares the key vendor +/// backend keys inline (the config's, the deterministic build's seed +/// object). A macro because the two structs are distinct types. +macro_rules! vendor_wire { + ($wire:expr) => { + $crate::config::VendorWire { + key_vendor: $wire.key_vendor, + keyset_records: $wire.keyset_records, + aws_region: $wire.aws_region, + aws_endpoint: $wire.aws_endpoint, + vault_address: $wire.vault_address, + vault_namespace: $wire.vault_namespace, + vault_mount: $wire.vault_mount, + gcp_endpoint: $wire.gcp_endpoint, + static_key_ref: $wire.static_key_ref, + static_key: $wire.static_key, + default_keyset: $wire.default_keyset, + root_key_ttl_ms: $wire.root_key_ttl_ms, + } + }; +} +// The deterministic build reads the same keys beside its seed. +#[cfg(feature = "deterministic-kms")] +pub(crate) use vendor_wire; /// Everything `se_cipher_init` needs to build the cipher. pub struct CipherConfig { - pub client_key: ClientKey, + /// The ZeroKMS client key. `None` for a client with a key vendor + /// backend and no ZeroKMS: the config then has no `client_id`, + /// `client_key` or `zerokms_url`. + pub client_key: Option, pub endpoint: Option, pub keyset_cache_size: Option, + /// The key vendor backend, when one is configured. + pub vendor: Option, } /// Parse the config frame a host sent. See the [module docs](self). @@ -53,14 +116,45 @@ pub fn parse_config(bytes: &[u8]) -> Result { /// [`parse_config`], with the refusal's message. fn read(bytes: &[u8]) -> Result { let wire: Wire<'_> = from_slice(bytes).map_err(|e| format!("the config: {e}"))?; - // `from_encoded_v1` is lenient by design: hex in either case *or* the - // base64 `secretkey.json` holds, matching every native loader. - let client_key = ClientKey::from_encoded_v1(wire.client_id.0, wire.client_key) - .map_err(|_| "the config: client_key: a client key that is not valid".to_owned())?; + let vendor = vendor_wire!(wire) + .config() + .map_err(|e| format!("the config: {e}"))?; + // A key vendor backend is an alternative to ZeroKMS: with one, and no + // ZeroKMS key at all, the client has no ZeroKMS. Any ZeroKMS key given + // makes the others required, as before. + let vendor_only = vendor.is_some() + && wire.client_id.is_none() + && wire.client_key.is_none() + && wire.zerokms_url.is_none(); + let client_key = if vendor_only { + None + } else { + let client_id = wire + .client_id + .ok_or_else(|| "the config: missing field `client_id`".to_owned())?; + let encoded = wire + .client_key + .ok_or_else(|| "the config: missing field `client_key`".to_owned())?; + // `from_encoded_v1` is lenient by design: hex in either case *or* + // the base64 `secretkey.json` holds, matching every native loader. + Some( + ClientKey::from_encoded_v1(client_id.0, encoded) + .map_err(|_| "the config: client_key: a client key that is not valid".to_owned())?, + ) + }; + // The default keyset of a client with ZeroKMS is the one a ZeroKMS + // administrator set; only a vendor-only client names its own. + if !vendor_only && vendor.as_ref().is_some_and(|v| v.default_keyset.is_some()) { + return Err( + "the config: default_keyset: a key that applies only to a client with no ZeroKMS" + .to_owned(), + ); + } Ok(CipherConfig { client_key, endpoint: wire.zerokms_url.map(|url| url.0), keyset_cache_size: wire.keyset_cache_size.map(|size| size.0), + vendor, }) } @@ -72,12 +166,40 @@ fn read(bytes: &[u8]) -> Result { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Wire<'a> { - client_id: ClientId, - client_key: &'a str, + #[serde(default, deserialize_with = "present")] + client_id: Option, + #[serde(default, borrow, deserialize_with = "present")] + client_key: Option<&'a str>, #[serde(default, deserialize_with = "present")] zerokms_url: Option, #[serde(default, deserialize_with = "present")] keyset_cache_size: Option, + // The key vendor backend keys, read into a `VendorWire`. Not a + // flattened field: serde does not deny unknown keys through a flatten. + #[serde(default, borrow, deserialize_with = "present")] + key_vendor: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + keyset_records: Option>, + #[serde(default, borrow, deserialize_with = "present")] + aws_region: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + aws_endpoint: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + vault_address: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + vault_namespace: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + vault_mount: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + gcp_endpoint: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + static_key_ref: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + static_key: Option<&'a [u8]>, + #[serde(default, borrow, deserialize_with = "present")] + default_keyset: Option>, + #[serde(default, borrow, deserialize_with = "present")] + root_key_ttl_ms: Option<&'a str>, } /// Absent is `None`. Present, a value must be a string: a null is refused. @@ -128,6 +250,165 @@ impl TryFrom<&str> for CacheSize { } } +/// The key vendor backend keys of a config, as a host writes them. Read +/// beside the ZeroKMS keys here, and beside the seed in the deterministic +/// build ([`crate::deterministic`]). +#[derive(Default)] +pub struct VendorWire<'a> { + pub(crate) key_vendor: Option<&'a str>, + pub(crate) keyset_records: Option>, + pub(crate) aws_region: Option<&'a str>, + pub(crate) aws_endpoint: Option<&'a str>, + pub(crate) vault_address: Option<&'a str>, + pub(crate) vault_namespace: Option<&'a str>, + pub(crate) vault_mount: Option<&'a str>, + pub(crate) gcp_endpoint: Option<&'a str>, + pub(crate) static_key_ref: Option<&'a str>, + pub(crate) static_key: Option<&'a [u8]>, + pub(crate) default_keyset: Option>, + pub(crate) root_key_ttl_ms: Option<&'a str>, +} + +/// `default_keyset` as a host writes it: `{"id": 16 bytes}` or +/// `{"name": string}`, one of the two. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KeysetSelectorWire<'a> { + #[serde(default, borrow, deserialize_with = "present")] + id: Option<&'a [u8]>, + #[serde(default, borrow, deserialize_with = "present")] + name: Option<&'a str>, +} + +impl VendorWire<'_> { + /// The key vendor backend these keys configure, or `None` when none is + /// given. A refusal names the key at fault and never its value. + pub fn config(self) -> Result, String> { + let given: Vec<&'static str> = [ + ("key_vendor", self.key_vendor.is_some()), + ("keyset_records", self.keyset_records.is_some()), + ("aws_region", self.aws_region.is_some()), + ("aws_endpoint", self.aws_endpoint.is_some()), + ("vault_address", self.vault_address.is_some()), + ("vault_namespace", self.vault_namespace.is_some()), + ("vault_mount", self.vault_mount.is_some()), + ("gcp_endpoint", self.gcp_endpoint.is_some()), + ("static_key_ref", self.static_key_ref.is_some()), + ("static_key", self.static_key.is_some()), + ("default_keyset", self.default_keyset.is_some()), + ("root_key_ttl_ms", self.root_key_ttl_ms.is_some()), + ] + .into_iter() + .filter_map(|(key, present)| present.then_some(key)) + .collect(); + if given.is_empty() { + return Ok(None); + } + let missing = |key: &str| format!("missing field `{key}`"); + let invalid = |key: &str| format!("{key}: a value its type does not accept"); + let name = self.key_vendor.ok_or_else(|| missing("key_vendor"))?; + let vendor = KeyVendor::from_name(name).ok_or_else(|| invalid("key_vendor"))?; + let records = self + .keyset_records + .ok_or_else(|| missing("keyset_records"))?; + if records.is_empty() { + return Err(invalid("keyset_records")); + } + let required = |key: &'static str, value: Option<&str>| { + let value = value.ok_or_else(|| missing(key))?; + if value.is_empty() { + return Err(invalid(key)); + } + Ok(value.to_owned()) + }; + let (settings, used): (VendorSettings, &[&str]) = match vendor { + KeyVendor::Aws => ( + VendorSettings::Aws { + region: required("aws_region", self.aws_region)?, + endpoint: self.aws_endpoint.map(str::to_owned), + }, + &["aws_region", "aws_endpoint"], + ), + KeyVendor::Vault => ( + VendorSettings::Vault { + address: required("vault_address", self.vault_address)?, + namespace: self.vault_namespace.map(str::to_owned), + mount: self.vault_mount.map(str::to_owned), + }, + &["vault_address", "vault_namespace", "vault_mount"], + ), + KeyVendor::Gcp => ( + VendorSettings::Gcp { + endpoint: self.gcp_endpoint.map(str::to_owned), + }, + &["gcp_endpoint"], + ), + KeyVendor::Azure => (VendorSettings::Azure, &[]), + KeyVendor::Static => { + let key = self.static_key.ok_or_else(|| missing("static_key"))?; + let mut bytes: [u8; 32] = key.try_into().map_err(|_| invalid("static_key"))?; + // `KeyBytes` wipes its own copy; the stack copy is wiped + // here. The config buffer is the ABI layer's to wipe. + let key = KeyBytes::new(bytes); + bytes.zeroize(); + ( + VendorSettings::Static { + key_ref: required("static_key_ref", self.static_key_ref)?, + key, + }, + &["static_key_ref", "static_key"], + ) + } + // `KeyVendor` is `#[non_exhaustive]`: a vendor this guest has no + // client for is not one it can configure. + _ => return Err(invalid("key_vendor")), + }; + let default_keyset = match self.default_keyset { + None => None, + Some(KeysetSelectorWire { + id: Some(id), + name: None, + }) => Some(DefaultKeyset::Id( + Uuid::from_slice(id).map_err(|_| invalid("default_keyset"))?, + )), + Some(KeysetSelectorWire { + id: None, + name: Some(name), + }) if !name.is_empty() => Some(DefaultKeyset::Name(name.to_owned())), + Some(_) => return Err(invalid("default_keyset")), + }; + let root_key_ttl = self + .root_key_ttl_ms + .map(|ms| { + ms.parse::() + .map(Duration::from_millis) + .map_err(|_| invalid("root_key_ttl_ms")) + }) + .transpose()?; + // Whatever else was given is for another backend. + const SHARED: [&str; 4] = [ + "key_vendor", + "keyset_records", + "default_keyset", + "root_key_ttl_ms", + ]; + if let Some(key) = given + .iter() + .find(|key| !SHARED.contains(key) && !used.contains(key)) + { + return Err(format!( + "{key}: a key that does not apply to its key_vendor" + )); + } + Ok(Some(VendorConfig { + settings, + records: records.into_iter().map(str::to_owned).collect(), + default_keyset, + root_key_ttl, + })) + } +} + #[cfg(test)] mod tests { use super::*; @@ -166,7 +447,7 @@ mod tests { ("client_key", s(&hex)), ]) .expect("minimal config parses"); - assert_eq!(cfg.client_key.key_id, id); + assert_eq!(cfg.client_key.as_ref().expect("zerokms").key_id, id); assert!(cfg.endpoint.is_none()); assert!(cfg.keyset_cache_size.is_none()); } @@ -251,9 +532,17 @@ mod tests { ("client_key", s(&encoded)), ]) .unwrap_or_else(|e| panic!("{label} must parse, got {e}")); - assert_eq!(cfg.client_key.key_id, id, "{label}"); assert_eq!( - cfg.client_key.to_hex_v1().expect("re-encode"), + cfg.client_key.as_ref().expect("zerokms").key_id, + id, + "{label}" + ); + assert_eq!( + cfg.client_key + .as_ref() + .expect("zerokms") + .to_hex_v1() + .expect("re-encode"), hex, "{label} must recover the same keyset" ); @@ -352,4 +641,246 @@ mod tests { ); assert!(message.starts_with("the config: "), "{message}"); } + + fn with_vendor(extra: Vec<(&'static str, V)>) -> Result { + let (id, hex) = client_key_hex(); + let mut entries = vec![("client_id", s(&id.to_string())), ("client_key", s(&hex))]; + entries.extend(extra); + config(entries) + } + + fn records() -> V { + V::Arr(vec![s("stack-keyset:AQ")]) + } + + #[test] + fn no_vendor_key_is_no_vendor() { + let cfg = with_vendor(vec![]).expect("config parses"); + assert!(cfg.vendor.is_none()); + } + + #[test] + fn parses_each_vendor() { + let aws = with_vendor(vec![ + ("key_vendor", s("aws-kms")), + ("keyset_records", records()), + ("aws_region", s("us-east-1")), + ("aws_endpoint", s("http://localhost:4566")), + ]) + .unwrap() + .vendor + .unwrap(); + assert!(matches!( + aws.settings, + VendorSettings::Aws { ref region, endpoint: Some(_) } if region == "us-east-1" + )); + assert_eq!(aws.records, vec!["stack-keyset:AQ".to_owned()]); + + let vault = with_vendor(vec![ + ("key_vendor", s("vault-transit")), + ("keyset_records", records()), + ("vault_address", s("https://vault.example.com")), + ("vault_mount", s("kms")), + ]) + .unwrap() + .vendor + .unwrap(); + assert!(matches!( + vault.settings, + VendorSettings::Vault { + namespace: None, + mount: Some(_), + .. + } + )); + + let gcp = with_vendor(vec![ + ("key_vendor", s("gcp-kms")), + ("keyset_records", records()), + ]) + .unwrap() + .vendor + .unwrap(); + assert!(matches!( + gcp.settings, + VendorSettings::Gcp { endpoint: None } + )); + let azure = with_vendor(vec![ + ("key_vendor", s("azure-key-vault")), + ("keyset_records", records()), + ]) + .unwrap() + .vendor + .unwrap(); + assert!(matches!(azure.settings, VendorSettings::Azure)); + + let static_key = with_vendor(vec![ + ("key_vendor", s("static")), + ("keyset_records", records()), + ("static_key_ref", s("local")), + ("static_key", V::Bytes(vec![7u8; 32])), + ]) + .unwrap() + .vendor + .unwrap(); + assert!(matches!(static_key.settings, VendorSettings::Static { .. })); + // The static key never prints. + assert!(!format!("{static_key:?}").contains('7')); + } + + /// Every refusal names the key at fault, never a value. + #[test] + fn refuses_bad_vendor_configs() { + let cases: Vec<(Vec<(&'static str, V)>, &str)> = vec![ + ( + vec![("keyset_records", records())], + "the config: missing field `key_vendor`", + ), + ( + vec![("key_vendor", s("zerokms")), ("keyset_records", records())], + "the config: key_vendor: a value its type does not accept", + ), + ( + vec![("key_vendor", s("azure-key-vault"))], + "the config: missing field `keyset_records`", + ), + ( + vec![ + ("key_vendor", s("azure-key-vault")), + ("keyset_records", V::Arr(vec![])), + ], + "the config: keyset_records: a value its type does not accept", + ), + ( + vec![ + ("key_vendor", s("azure-key-vault")), + ("keyset_records", s("stack-keyset:AQ")), + ], + "the config: keyset_records: ", + ), + ( + vec![("key_vendor", s("aws-kms")), ("keyset_records", records())], + "the config: missing field `aws_region`", + ), + ( + vec![ + ("key_vendor", s("aws-kms")), + ("keyset_records", records()), + ("aws_region", s("")), + ], + "the config: aws_region: a value its type does not accept", + ), + ( + vec![ + ("key_vendor", s("aws-kms")), + ("keyset_records", records()), + ("aws_region", s("us-east-1")), + ("vault_address", s("https://vault.example.com")), + ], + "the config: vault_address: a key that does not apply to its key_vendor", + ), + ( + vec![ + ("key_vendor", s("static")), + ("keyset_records", records()), + ("static_key_ref", s("local")), + ("static_key", s("not bytes")), + ], + "the config: static_key: ", + ), + ( + vec![ + ("key_vendor", s("static")), + ("keyset_records", records()), + ("static_key_ref", s("local")), + ("static_key", V::Bytes(vec![7u8; 31])), + ], + "the config: static_key: a value its type does not accept", + ), + ]; + let (_, hex) = client_key_hex(); + for (entries, expected) in cases { + let message = with_vendor(entries).err().expect("refused"); + assert!( + message.starts_with(expected), + "{message:?} is not {expected:?}" + ); + for secret in [hex.as_str(), "us-east-1", "vault.example.com", "not bytes"] { + assert!(!message.contains(secret), "{message:?} echoes {secret:?}"); + } + } + } + + #[test] + fn a_vendor_and_no_zerokms_key_is_a_vendor_only_client() { + let cfg = config(vec![ + ("key_vendor", s("gcp-kms")), + ("keyset_records", records()), + ("default_keyset", obj(vec![("name", s("payments"))])), + ("root_key_ttl_ms", s("60000")), + ]) + .expect("config parses"); + assert!(cfg.client_key.is_none()); + let vendor = cfg.vendor.expect("vendor"); + assert_eq!( + vendor.default_keyset, + Some(DefaultKeyset::Name("payments".into())) + ); + assert_eq!(vendor.root_key_ttl, Some(Duration::from_secs(60))); + + // One ZeroKMS key makes the others required. + let message = config(vec![ + ("key_vendor", s("gcp-kms")), + ("keyset_records", records()), + ("zerokms_url", s("https://zerokms.example.com")), + ]) + .err() + .expect("refused"); + assert_eq!(message, "the config: missing field `client_id`"); + // No vendor and no ZeroKMS key is still a missing client id. + assert_eq!( + config(vec![]).err().as_deref(), + Some("the config: missing field `client_id`") + ); + } + + #[test] + fn a_default_keyset_needs_a_client_with_no_zerokms() { + let message = with_vendor(vec![ + ("key_vendor", s("gcp-kms")), + ("keyset_records", records()), + ("default_keyset", obj(vec![("id", V::Bytes(vec![1; 16]))])), + ]) + .err() + .expect("refused"); + assert_eq!( + message, + "the config: default_keyset: a key that applies only to a client with no ZeroKMS" + ); + + for bad in [ + s("payments"), + obj(vec![]), + obj(vec![("name", s(""))]), + obj(vec![("id", V::Bytes(vec![1; 15]))]), + obj(vec![("name", s("a")), ("id", V::Bytes(vec![1; 16]))]), + ] { + assert!(config(vec![ + ("key_vendor", s("gcp-kms")), + ("keyset_records", records()), + ("default_keyset", bad), + ]) + .is_err()); + } + assert_eq!( + config(vec![ + ("key_vendor", s("gcp-kms")), + ("keyset_records", records()), + ("root_key_ttl_ms", s("-1")), + ]) + .err() + .as_deref(), + Some("the config: root_key_ttl_ms: a value its type does not accept") + ); + } } diff --git a/languages/golang/encrypt/guest/src/deterministic.rs b/languages/golang/encrypt/guest/src/deterministic.rs index 15567525a5..a30356343b 100644 --- a/languages/golang/encrypt/guest/src/deterministic.rs +++ b/languages/golang/encrypt/guest/src/deterministic.rs @@ -17,8 +17,9 @@ use stack_encrypt::dynamic::frame::serde::from_slice; use stack_guest_abi::last_error::malformed; use zeroize::Zeroize; -/// The `deterministic-kms` build's whole config: the source's 32-byte seed, -/// a frame holding bytes and nothing else. Wiped when it drops. +/// The `deterministic-kms` build's ZeroKMS stand-in config: the source's +/// 32-byte seed, a frame holding bytes and nothing else. Wiped when it +/// drops. #[derive(Deserialize)] #[serde(try_from = "&[u8]")] pub struct Seed([u8; 32]); @@ -40,13 +41,77 @@ impl Drop for Seed { } } -impl Seed { - /// Read the seed frame the host sent in place of a config. +/// The `deterministic-kms` build's whole config: the seed, and a key vendor +/// backend when one is configured. The host sends the bare seed, or, for +/// the hermetic Go tests of the vendor path, an object of the seed and the +/// key vendor backend keys ([`crate::config`] has them): +/// `{"seed": bytes, "key_vendor": .., ..}`. +pub struct SeedConfig { + pub seed: Seed, + pub vendor: Option, +} + +impl SeedConfig { + /// Read the config the host sent. pub fn parse(bytes: &[u8]) -> Result { - from_slice(bytes) - .map_err(|_| malformed("the deterministic build's config is a 32-byte seed")) + if let Ok(seed) = from_slice::(bytes) { + return Ok(Self { seed, vendor: None }); + } + let wire: SeededWire<'_> = from_slice(bytes).map_err(|_| { + malformed( + "the deterministic build's config is a 32-byte seed, or an object of a seed and the key vendor backend keys", + ) + })?; + let vendor = crate::config::vendor_wire!(wire) + .config() + .map_err(|e| malformed(format!("the config: {e}")))?; + Ok(Self { + seed: wire.seed, + vendor, + }) } +} + +/// The object form of the deterministic config. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SeededWire<'a> { + seed: Seed, + #[serde(default, borrow, deserialize_with = "present")] + key_vendor: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + keyset_records: Option>, + #[serde(default, borrow, deserialize_with = "present")] + aws_region: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + aws_endpoint: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + vault_address: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + vault_namespace: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + vault_mount: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + gcp_endpoint: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + static_key_ref: Option<&'a str>, + #[serde(default, borrow, deserialize_with = "present")] + static_key: Option<&'a [u8]>, + #[serde(default, borrow, deserialize_with = "present")] + default_keyset: Option>, + #[serde(default, borrow, deserialize_with = "present")] + root_key_ttl_ms: Option<&'a str>, +} +/// Absent is `None`. Present, a value must be of its type: a null is +/// refused. +fn present<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>( + d: D, +) -> Result, D::Error> { + T::deserialize(d).map(Some) +} + +impl Seed { /// The key source this seed drives. pub fn source(&self) -> DeterministicSource { DeterministicSource::new(self.0) @@ -55,8 +120,8 @@ impl Seed { #[cfg(test)] mod tests { - use super::Seed; - use crate::test_frame::{frame, V}; + use super::{Seed, SeedConfig}; + use crate::test_frame::{frame, obj, s, V}; use stack_encrypt::dynamic::frame::serde::from_slice; /// A seed is 32 bytes, and only bytes: 32 characters of text are refused. @@ -66,4 +131,30 @@ mod tests { assert!(from_slice::(&frame(&V::Bytes(vec![7; 31]))).is_err()); assert!(from_slice::(&frame(&V::Str("x".repeat(32)))).is_err()); } + + /// The object form adds a key vendor backend beside the seed; a bare + /// seed has none. + #[test] + fn a_seed_object_carries_a_key_vendor_backend() { + let bare = SeedConfig::parse(&frame(&V::Bytes(vec![7; 32]))).unwrap(); + assert!(bare.vendor.is_none()); + let with = SeedConfig::parse(&frame(&obj(vec![ + ("seed", V::Bytes(vec![7; 32])), + ("key_vendor", s("static")), + ("keyset_records", V::Arr(vec![s("stack-keyset:AQ")])), + ("static_key_ref", s("local")), + ("static_key", V::Bytes(vec![9; 32])), + ]))) + .unwrap(); + assert!(matches!( + with.vendor.map(|v| v.settings), + Some(crate::vendor::VendorSettings::Static { .. }) + )); + assert!(SeedConfig::parse(&frame(&obj(vec![("seed", V::Bytes(vec![7; 31]))]))).is_err()); + assert!(SeedConfig::parse(&frame(&obj(vec![ + ("seed", V::Bytes(vec![7; 32])), + ("other", s("x")), + ]))) + .is_err()); + } } diff --git a/languages/golang/encrypt/guest/src/host.rs b/languages/golang/encrypt/guest/src/host.rs index 605bf7d42c..93df54ce71 100644 --- a/languages/golang/encrypt/guest/src/host.rs +++ b/languages/golang/encrypt/guest/src/host.rs @@ -1,7 +1,10 @@ -//! The guest's two connections to its host: [`WasiHostConnection`] (a +//! The guest's connections to its host: [`WasiHostConnection`] (a //! [`ZeroKMSConnection`] whose requests the host performs, through the step -//! loop) and [`HostTokenStrategy`] (an [`AuthStrategy`] that asks the host -//! for the bearer token). wasm32-only — the token is an imported function. +//! loop), [`HostTokenStrategy`] (an [`AuthStrategy`] that asks the host for +//! the bearer token) and [`StepCredentialImport`] (a key vendor backend's +//! short-lived credentials, asked for through the step loop, see +//! [`crate::vendor`]). wasm32-only — the token is an imported function, and +//! a step's future is not `Send`. //! //! # Requests //! @@ -10,7 +13,10 @@ //! returns to the host with it, paused; the host performs it and resumes the //! operation with the response ([`crate::io`] has the wire form). So a host //! whose HTTP client cannot block — `fetch` on the edge — can run the guest, -//! and the guest is never running while a request is in flight. +//! and the guest is never running while a request is in flight. A key +//! vendor backend's requests, its credentials and its backoff waits pause +//! the operation the same way (`stack_guest_abi::vendor_transport`, +//! [`StepCredentialImport`]). //! //! # Import contract (module `cipherstash_transport`) //! @@ -31,6 +37,25 @@ //! bytes. Key material derived from a response never crosses back — the //! derivation runs inside the guest ([`stack_kms`]'s client, unmodified). //! +//! A key vendor backend does **not** keep that property. A vendor request +//! crosses as the signed HTTP request the vendor would receive over TLS, +//! and the host does the TLS. So the host, and any RoundTripper the Go +//! caller gives it, see the vendor traffic in plaintext: the key a wrap +//! request sends, and the key that an unwrap response returns (AWS +//! `Decrypt`, Google Cloud KMS and Vault `decrypt`, Azure `unwrapkey`). +//! That is a keyset's root key. Unlike a ZeroKMS response, +//! it is usable alone. The host wipes the request and response bodies it +//! holds once they are in guest memory, but not the buffers of net/http, +//! TLS or the RoundTripper. Keys the guest derives from it (index keys, +//! derived data keys) never cross back. +//! +//! What crosses *in* for a credential step is the credential the host's +//! source returns, which the guest uses to sign and then drops. It is +//! short-lived when the source is a role or token chain, and long-lived +//! when the source returns fixed keys (Go's `AWSCredentialsFromEnv` with no +//! session token, a fixed Vault token). A `StaticKey` backend's key crosses +//! once, in the guest config. +//! //! Request bodies and response bodies can carry key-material contexts and //! wrapped keys, so both are wiped on drop here; the buffers the host wrote //! are reclaimed via the registry (which the ABI's `se_dealloc` also wipes). @@ -42,12 +67,14 @@ use stack_auth::{AuthError, AuthStrategy, CustomError, SecretToken, ServiceToken use stack_guest_abi::buffers; use stack_guest_abi::headers::header_value; use stack_guest_abi::step; +use stack_kms::{BackendErrorKind, KeyVendor}; use stack_kms::{BaseUrlUnresolved, ZeroKMSConnection, ZeroKMSConnectionInit, ZeroKmsEndpoint}; use zeroize::Zeroizing; use zerokms_protocol::{ViturRequest, ViturRequestError}; use crate::headers::request_headers; use crate::response::map_response; +use crate::vendor::{CredentialError, CredentialImport}; #[link(wasm_import_module = "cipherstash_transport")] extern "C" { @@ -203,3 +230,37 @@ impl AuthStrategy for &HostTokenStrategy { Ok(ServiceToken::new(SecretToken::new(text))) } } + +/// A key vendor backend's [`CredentialImport`] through the step loop: the +/// operation pauses with a credential request for the vendor and scope, and +/// the host answers it from its credential source between steps, outside +/// the client's lock, where a source may wait on the network. The guest asks +/// before each vendor request and keeps nothing, so the host decides when a +/// credential is refreshed. +pub struct StepCredentialImport; + +impl CredentialImport for StepCredentialImport { + async fn credential( + &self, + vendor: KeyVendor, + scope: &str, + ) -> Result>, CredentialError> { + let failed = |message: String| CredentialError { + kind: BackendErrorKind::Unavailable, + message, + }; + let response = step::request(step::Request::credential(vendor.byte(), scope)) + .await + .map_err(|e| failed(e.to_string()))?; + if response.status != 0 { + // The body is the host's error text: the credential source's + // reason, never a credential. + return Err(failed(format!( + "the host has no {} credential: {}", + vendor.name(), + String::from_utf8_lossy(&response.body) + ))); + } + Ok(response.body) + } +} diff --git a/languages/golang/encrypt/guest/src/io.rs b/languages/golang/encrypt/guest/src/io.rs index dad3a6e63d..520be2cf36 100644 --- a/languages/golang/encrypt/guest/src/io.rs +++ b/languages/golang/encrypt/guest/src/io.rs @@ -12,6 +12,15 @@ //! "headers": , "body": }, ... ] } //! ``` //! +//! A request is one of three objects. Besides the HTTP request above, a key +//! vendor backend's credential (`vendor` is its `KeyVendor` byte, `scope` +//! the OAuth scope or empty) and a backoff wait: +//! +//! ```text +//! { "credential": { "vendor": , "scope": } } +//! { "wait_ms": } +//! ``` +//! //! The host performs every request and calls `se_resume` with the handle and //! their responses, in the same order: //! @@ -22,7 +31,10 @@ //! `headers` is the `name: value` line format of //! [`stack_guest_abi::headers`] both ways. A `status` is the HTTP status, or //! negative for a request the host could not perform, with its error text as -//! the body: the contract `transport_send` had, field for field. A step +//! the body: the contract `transport_send` had, field for field. A +//! credential's response has status `0` and the credential, as a frame, in +//! its body ([`crate::vendor`] has its keys), or a negative status and the +//! error text; a wait's has status `0` and an empty body. A step //! carries the bearer token and a request body that can hold key-material //! contexts, and a response can carry wrapped keys, so the guest wipes its //! copies, and the host wipes the buffers with `se_dealloc`. @@ -46,23 +58,56 @@ pub fn encode_step(handle: u64, requests: &[Request]) -> Result, u32> { requests: Vec>, } #[derive(Serialize)] - struct RequestWire<'a> { - method: &'a str, - url: &'a str, - headers: Bytes<'a>, - body: Bytes<'a>, + #[serde(untagged)] + enum RequestWire<'a> { + Http { + method: &'a str, + url: &'a str, + headers: Bytes<'a>, + body: Bytes<'a>, + }, + Credential { + credential: CredentialWire<'a>, + }, + Wait { + wait_ms: u64, + }, + } + #[derive(Serialize)] + struct CredentialWire<'a> { + vendor: u32, + scope: &'a str, } let wire = Wire { handle, requests: requests .iter() - .map(|request| RequestWire { - method: request.method, - url: &request.url, - headers: Bytes(&request.headers), - body: Bytes(&request.body), + .map(|request| match request { + Request::Http(request) => Ok(RequestWire::Http { + method: request.method, + url: &request.url, + headers: Bytes(&request.headers), + body: Bytes(&request.body), + }), + Request::Credential { vendor, scope } => Ok(RequestWire::Credential { + credential: CredentialWire { + vendor: u32::from(*vendor), + scope, + }, + }), + Request::Wait(duration) => Ok(RequestWire::Wait { + // A wait longer than u64 milliseconds is not one a + // vendor client asks for; it is capped, not refused. + wait_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX), + }), + // `Request` is non-exhaustive: a kind this guest cannot + // encode is a bug here, refused rather than dropped. + #[allow(unreachable_patterns)] + _ => Err(stack_guest_abi::last_error::internal( + "a step request this guest cannot encode", + )), }) - .collect(), + .collect::>()?, }; // A `Buffer`, which wipes on drop and as it grows. let frame = to_vec(&wire).map_err(|_| STATUS_ENCODING)?; @@ -149,6 +194,34 @@ mod tests { assert_eq!(bytes.capacity(), bytes.len()); } + /// A credential and a wait cross as objects of their own, beside the + /// HTTP request's. + #[test] + fn a_credential_and_a_wait_are_step_requests() { + let requests = [ + Request::credential(3, "https://vault.azure.net/.default"), + Request::wait(std::time::Duration::from_millis(250)), + ]; + let bytes = encode_step(9, &requests).unwrap(); + let expected = frame(&obj(vec![ + ("handle", V::U64(9)), + ( + "requests", + V::Arr(vec![ + obj(vec![( + "credential", + obj(vec![ + ("vendor", V::U32(3)), + ("scope", V::Str("https://vault.azure.net/.default".into())), + ]), + )]), + obj(vec![("wait_ms", V::U64(250))]), + ]), + ), + ])); + assert_eq!(bytes, expected); + } + #[test] fn responses_read_in_order() { let bytes = frame(&V::Arr(vec![ diff --git a/languages/golang/encrypt/guest/src/lib.rs b/languages/golang/encrypt/guest/src/lib.rs index 2d3c7acd32..bdd52d6223 100644 --- a/languages/golang/encrypt/guest/src/lib.rs +++ b/languages/golang/encrypt/guest/src/lib.rs @@ -38,11 +38,19 @@ //! //! Control stays in Rust: request assembly, key derivation, batching, and //! AAD/PRF context binding run unmodified inside the guest. The host -//! provides exactly two imports (HTTP transport and the bearer token — see -//! [`host`]); what crosses the boundary per call is a value tree in, a -//! ciphertext/record tree out, and — inside the call — the same bytes that -//! would cross TLS anyway. The client key enters guest memory once at -//! `se_cipher_init`; derived data keys and index keys never leave. +//! provides exactly one import (the ZeroKMS bearer token — see [`host`]); +//! every HTTP request, a key vendor backend's short-lived credential and a +//! retry's backoff go to the host through the step loop instead, so the +//! guest never waits on the network. What crosses the boundary per call is +//! a value tree in, a ciphertext/record tree out, and — between its steps — +//! the same bytes that would cross TLS anyway. The client key (and a static key backend's key) enters guest +//! memory once at `se_cipher_init`; root keys, derived data keys and index +//! keys never leave. +//! +//! A client holds ZeroKMS and at most one key vendor backend (AWS KMS, +//! Google Cloud KMS, Azure Key Vault, Vault Transit or a static key), routed +//! by keyset ([`vendor`]). The vendor clients sign their requests here, with +//! credentials the host supplies, through the step loop, per request. //! //! The exports are the record path (`se_encrypt_record`, `se_decrypt_record`), //! the term of one index under a context (`se_term`, which the Go SDK asks @@ -69,7 +77,7 @@ //! Split into: //! //! - [`ops`], [`options`], [`config`], [`response`], [`headers`], -//! [`status`], [`targets`] — everything that is pure logic over +//! [`status`], [`targets`], [`vendor`] — everything that is pure logic over //! `StackCipher` / `KeysetCipher` / bytes. Compiles and unit-tests //! on the native host target (`cargo test` here, no wasm toolchain //! needed) against `stack_kms::FakeDataKeySource`. @@ -103,6 +111,8 @@ mod support; pub mod targets; #[cfg(test)] mod test_frame; +pub mod vendor; +pub mod zerokms; // The ABI's packed u64 results embed 32-bit pointers, its bounds checks // read the wasm linear-memory size, and `host` calls imported functions — diff --git a/languages/golang/encrypt/guest/src/status.rs b/languages/golang/encrypt/guest/src/status.rs index 7cfbdabd07..81ab2aa9d1 100644 --- a/languages/golang/encrypt/guest/src/status.rs +++ b/languages/golang/encrypt/guest/src/status.rs @@ -66,7 +66,7 @@ use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; pub use stack_guest_abi::status::{ STATUS_AUTH, STATUS_CONTEXT_MISMATCH, STATUS_ENCODING, STATUS_FOREIGN_KEYSET, STATUS_INTERNAL, STATUS_KMS_CONFLICT, STATUS_KMS_FORBIDDEN, STATUS_KMS_NOT_FOUND, STATUS_KMS_OTHER, - STATUS_KMS_TRANSPORT, STATUS_KMS_UNAUTHORIZED, STATUS_STATE, STATUS_TERM, + STATUS_KMS_TRANSPORT, STATUS_KMS_UNAUTHORIZED, STATUS_NO_ZEROKMS, STATUS_STATE, STATUS_TERM, }; /// Record a sealing/opening error as the last error @@ -109,9 +109,43 @@ pub fn describe(cause: &(dyn std::error::Error + 'static)) -> Option { { return Some(cause.to_string()); } + if describes_itself(cause) { + return Some(cause.to_string()); + } last_error::describe_std(cause) } +/// The key vendor backend errors whose text the guest vouches for: each +/// vendor crate builds its messages from the operation, the HTTP status, the +/// vendor's error code and message, and fixed text, and never puts a key, +/// a secret, a token or a value in one (CIP-5307 to CIP-5309). A host +/// credential failure is described by the guest itself ([`crate::vendor`]). +fn describes_itself(cause: &(dyn std::error::Error + 'static)) -> bool { + cause.is::() + || cause.is::() + || cause.is::() + || cause.is::() + || cause.is::() + || cause.is::() + || cause.is::() + || cause.is::() + || cause.is::() + || cause.is::() + || cause.is::() +} + +/// A keyset record or the key vendor backend refused at `se_cipher_init`: +/// record the error and return its status. A record that does not parse, +/// names another vendor or an unknown mode, or repeats an id or a name is the host's input (`STATUS_ENCODING`); a key failure +/// keeps its own status. +pub fn fail_record(error: &stack_kms_vendor::RecordError) -> u32 { + let status = match error { + stack_kms_vendor::RecordError::Kms(kms) => status_for_kms(kms), + _ => STATUS_ENCODING, + }; + record(error, status) +} + /// Map a sealing/opening error onto the ABI status word. /// /// Total over [`stack_encrypt::Error`] (which is `#[non_exhaustive]`, so the @@ -240,6 +274,29 @@ fn status_for_kms(error: &stack_kms::Error) -> u32 { STATUS_KMS_TRANSPORT } stack_kms::Error::Unexpected(_) => STATUS_KMS_OTHER, + // A vendor-only client asked for a ZeroKMS keyset (CIP-5311). + e if crate::zerokms::is_no_zerokms(e) => STATUS_NO_ZEROKMS, + stack_kms::Error::Backend { kind, .. } => status_for_backend(*kind), + // A request for a lock context, a decryption policy or an + // unverified context against a key vendor backend: the caller's + // input, refused before any key call. + stack_kms::Error::ZeroKmsOnly { .. } => STATUS_ENCODING, + } +} + +/// A key vendor backend's failure as a status. `BackendErrorKind` is +/// `#[non_exhaustive]`, so a kind added upstream falls to +/// [`STATUS_KMS_OTHER`] until it is classified here. +fn status_for_backend(kind: stack_kms::BackendErrorKind) -> u32 { + use stack_kms::BackendErrorKind as Kind; + match kind { + Kind::NotFound => STATUS_KMS_NOT_FOUND, + Kind::AccessDenied => STATUS_KMS_FORBIDDEN, + Kind::Throttled | Kind::Unavailable => STATUS_KMS_TRANSPORT, + // The vendor could not unwrap the key: damaged, or wrapped by + // another key. Like an AEAD failure, the stored bytes do not open. + Kind::InvalidCiphertext => STATUS_AUTH, + _ => STATUS_KMS_OTHER, } } @@ -315,6 +372,30 @@ mod tests { assert_eq!(status_for_error(&load), STATUS_KMS_UNAUTHORIZED); } + #[test] + fn a_key_vendor_backend_failure_maps_by_its_kind() { + use stack_kms::BackendErrorKind as Kind; + let status = |kind| { + status_for_error(&stack_encrypt::Error::Kms(stack_kms::Error::Backend { + backend: stack_kms::KeyVendor::Aws.into(), + kind, + source: Box::new(std::io::Error::other("detail")), + })) + }; + assert_eq!(status(Kind::NotFound), STATUS_KMS_NOT_FOUND); + assert_eq!(status(Kind::AccessDenied), STATUS_KMS_FORBIDDEN); + assert_eq!(status(Kind::Throttled), STATUS_KMS_TRANSPORT); + assert_eq!(status(Kind::Unavailable), STATUS_KMS_TRANSPORT); + assert_eq!(status(Kind::InvalidCiphertext), STATUS_AUTH); + assert_eq!( + status_for_error(&stack_encrypt::Error::Kms(stack_kms::Error::ZeroKmsOnly { + vendor: stack_kms::KeyVendor::Aws, + feature: stack_kms::ZeroKmsFeature::DecryptionPolicy, + })), + STATUS_ENCODING + ); + } + #[test] fn aead_and_composition_errors_map_to_the_vitaminc_codes() { assert_eq!(status_for_error(&stack_encrypt::Error::Aead), STATUS_AUTH); diff --git a/languages/golang/encrypt/guest/src/test_frame.rs b/languages/golang/encrypt/guest/src/test_frame.rs index 3f4693ae92..61b8bf07d8 100644 --- a/languages/golang/encrypt/guest/src/test_frame.rs +++ b/languages/golang/encrypt/guest/src/test_frame.rs @@ -13,6 +13,7 @@ pub(crate) enum V { I32(i32), U32(u32), U64(u64), + I64(i64), Bool(bool), Null, } @@ -57,6 +58,7 @@ pub(crate) fn frame(v: &V) -> Vec { V::I32(n) => scalar(to_vec(n)), V::U32(n) => scalar(to_vec(n)), V::U64(n) => scalar(to_vec(n)), + V::I64(n) => scalar(to_vec(n)), V::Bool(b) => scalar(to_vec(b)), V::Null => scalar(to_vec(&())), } diff --git a/languages/golang/encrypt/guest/src/vendor.rs b/languages/golang/encrypt/guest/src/vendor.rs new file mode 100644 index 0000000000..e1de1475a0 --- /dev/null +++ b/languages/golang/encrypt/guest/src/vendor.rs @@ -0,0 +1,916 @@ +//! The key vendor backend of this guest (ADR-0013, ADR-0014, CIP-5311). +//! +//! A client holds ZeroKMS and at most one key vendor backend. The host +//! names the backend and gives its keyset records in the `se_cipher_init` +//! config ([`crate::config`]); this module builds it: +//! +//! * [`GuestVendor`] is one enum over the four vendor clients and the static +//! key backend, so the cipher has one key source type whatever the host +//! chose: `KeyRouter`. With no backend configured +//! there is no `GuestVendor`: the router is +//! `KeyRouter::zerokms_only`, and sends every request to ZeroKMS, as +//! before. +//! * The vendor clients send through the host's HTTP client +//! (`StepVendorTransport`: each request, and each backoff wait, pauses the +//! operation through the step loop) and sign each request here, in the +//! guest. +//! * The credentials they sign with come from the host through the step +//! loop too ([`CredentialImport`]; the guest's is +//! `crate::host::StepCredentialImport`): the operation pauses with a +//! credential request, and the host answers it between steps, outside the +//! client's lock. Short-lived credentials only, never a long-lived +//! signing key: AWS temporary credentials, a Google OAuth access token, an +//! Azure access token for the requested scope, a Vault client token. The +//! guest asks before each request and keeps nothing between requests, so +//! the host's own credential chain decides when to refresh. +//! +//! What this module owns is pure logic over those seams, and it is tested +//! natively with a fake transport and a fake import. The wasm32 ones are +//! `stack_guest_abi::vendor_transport`'s and `crate::host`'s. +//! +//! # The credential the host returns +//! +//! One frame, an object of string values plus an optional `expires_at` +//! (`i64`, Unix seconds), in the body of the credential step's response: +//! +//! | vendor | keys | +//! |-----------------|------| +//! | `aws-kms` | `access_key_id`, `secret_access_key`, optional `session_token` | +//! | `vault-transit` | `token` | +//! | `gcp-kms` | `token` | +//! | `azure-key-vault` | `token` (for the requested scope) | +//! +//! A credential whose `expires_at` has passed is refused before the +//! request is signed. Every secret is held in `Zeroizing` or the vendor +//! crate's own wiped type, and none is ever put in an error message. + +use std::time::{Duration, SystemTime}; + +use serde::Deserialize; +use stack_encrypt::dynamic::frame::serde::from_slice; +use stack_kms::{BackendErrorKind, Error, KeyVendor, MaybeSend}; +use stack_kms_vendor::{ + KeyBytes, KeyVendorBackend, KeysetRecord, RecordError, StaticKeyVendor, VendorKeySource, + VendorTransport, +}; +use zeroize::Zeroizing; + +/// What the host configured: one key vendor backend and its settings. +pub enum VendorSettings { + /// AWS KMS in `region`; `endpoint` overrides the regional endpoint + /// (LocalStack, a VPC endpoint). + Aws { + region: String, + endpoint: Option, + }, + /// HashiCorp Vault Transit at `address`. + Vault { + address: String, + namespace: Option, + mount: Option, + }, + /// Google Cloud KMS; `endpoint` overrides the global endpoint. + Gcp { endpoint: Option }, + /// Azure Key Vault or Managed HSM: the key identifier in each keyset + /// record names the vault, so there is nothing to configure. + Azure, + /// The static key backend: one key, held in guest memory for the life + /// of the client. Derived data keys only. + Static { key_ref: String, key: KeyBytes }, +} + +impl std::fmt::Debug for VendorSettings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // No setting prints: the static key is one of them. The vendor + // prints once it is built ([`GuestVendor`]'s `Debug`). + f.debug_struct("VendorSettings").finish_non_exhaustive() + } +} + +/// The key vendor backend part of the config: the backend, the keyset +/// record texts, and the adapter's settings. +#[derive(Debug)] +pub struct VendorConfig { + pub settings: VendorSettings, + pub records: Vec, + /// The default keyset of a client with no ZeroKMS. `None` is the only + /// record, when there is one. + pub default_keyset: Option, + /// How long a root key stays unwrapped. `None` is the adapter's default + /// (`DEFAULT_ROOT_KEY_TTL`). + pub root_key_ttl: Option, +} + +/// A keyset named by one of the keyset records, by its id or its name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DefaultKeyset { + Id(uuid::Uuid), + Name(String), +} + +/// The host could not supply a credential, or supplied one the guest +/// refuses. The message never holds a secret. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CredentialError { + pub kind: BackendErrorKind, + pub message: String, +} + +impl CredentialError { + fn denied(message: impl Into) -> Self { + Self { + kind: BackendErrorKind::AccessDenied, + message: message.into(), + } + } +} + +/// Where the guest's vendor credentials come from, as a seam: one +/// short-lived credential for `vendor` and `scope`, as the frame bytes the +/// host wrote. `scope` is the OAuth scope for Google Cloud and Azure, and +/// empty for AWS and Vault. +/// +/// The guest asks before each vendor request and keeps nothing, so a host +/// source that refreshes needs no notice from the guest. +pub trait CredentialImport: Send + Sync { + /// The current credential, or why the host has none. + fn credential( + &self, + vendor: KeyVendor, + scope: &str, + ) -> impl std::future::Future>, CredentialError>> + MaybeSend; +} + +/// A credential as the host writes it. Every key is optional here; which +/// ones a vendor needs, and which it refuses, is [`CredentialFields`]'s. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CredentialWire<'a> { + #[serde(default, borrow)] + access_key_id: Option<&'a str>, + #[serde(default, borrow)] + secret_access_key: Option<&'a str>, + #[serde(default, borrow)] + session_token: Option<&'a str>, + #[serde(default, borrow)] + token: Option<&'a str>, + #[serde(default)] + expires_at: Option, +} + +/// A credential's fields, read out of the host's value. +struct CredentialFields { + strings: Vec<(String, Zeroizing)>, + /// What is left of its life. `None` when the host gave no expiry. + remaining: Option, +} + +impl CredentialFields { + /// Read `bytes`, the host's credential for `vendor`, at `now`. + fn read(vendor: KeyVendor, bytes: &[u8], now: SystemTime) -> Result { + let not_valid = || { + CredentialError::denied(format!( + "the host's {} credential is not an object of string values", + vendor.name() + )) + }; + let wire: CredentialWire<'_> = from_slice(bytes).map_err(|_| not_valid())?; + let mut remaining = None; + if let Some(seconds) = wire.expires_at { + let expires = u64::try_from(seconds) + .ok() + .and_then(|s| SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(s))) + .ok_or_else(not_valid)?; + let left = expires.duration_since(now).ok().filter(|d| !d.is_zero()); + remaining = Some(left.ok_or_else(|| { + CredentialError::denied(format!( + "the host's {} credential has expired", + vendor.name() + )) + })?); + } + let mut strings = Vec::with_capacity(4); + for (key, text) in [ + ("access_key_id", wire.access_key_id), + ("secret_access_key", wire.secret_access_key), + ("session_token", wire.session_token), + ("token", wire.token), + ] { + let Some(text) = text else { continue }; + // A value read from a file keeps its newline; no secret here has + // meaningful surrounding whitespace. An interior control + // character is refused: it would split a header. + let text = Zeroizing::new(text.trim().to_owned()); + if text.chars().any(char::is_control) { + return Err(CredentialError::denied(format!( + "the host's {} credential has a control character in `{key}`", + vendor.name() + ))); + } + strings.push((key.to_owned(), text)); + } + Ok(Self { strings, remaining }) + } + + /// Take the non-empty string `key`, or `None`. + fn take(&mut self, key: &str) -> Option> { + let at = self.strings.iter().position(|(k, _)| k == key)?; + let (_, value) = self.strings.swap_remove(at); + (!value.is_empty()).then_some(value) + } + + /// Take the required string `key`. + fn require( + &mut self, + vendor: KeyVendor, + key: &str, + ) -> Result, CredentialError> { + self.take(key).ok_or_else(|| { + CredentialError::denied(format!( + "the host's {} credential has no `{key}`", + vendor.name() + )) + }) + } + + /// Refuse a key this vendor's credential does not have: a host that + /// sends `token` for AWS has the vendor wrong, and must be told. + fn finish(self, vendor: KeyVendor) -> Result, CredentialError> { + match self.strings.first() { + Some((key, _)) => Err(CredentialError::denied(format!( + "the host's {} credential has a key it does not use: `{key}`", + vendor.name() + ))), + None => Ok(self.remaining), + } + } +} + +/// Read a token credential (Vault, Google Cloud, Azure): the token and what +/// is left of its life. +fn read_token( + vendor: KeyVendor, + bytes: &[u8], + now: SystemTime, +) -> Result<(Zeroizing, Option), CredentialError> { + let mut fields = CredentialFields::read(vendor, bytes, now)?; + let token = fields.require(vendor, "token")?; + let remaining = fields.finish(vendor)?; + Ok((token, remaining)) +} + +/// The clock the credential expiry is checked against. A seam for tests. +pub type Now = fn() -> SystemTime; + +/// The credential and token providers of the vendor clients, over the +/// host's credentials. One type serves AWS (`CredentialsProvider`) and the +/// three token providers. +pub struct HostCredentials { + import: C, + now: Now, +} + +impl HostCredentials { + /// Providers over `import`, checking expiry against the system clock. + pub fn new(import: C) -> Self { + Self { + import, + now: SystemTime::now, + } + } + + /// The same, with `now` as the clock. + pub fn with_now(mut self, now: Now) -> Self { + self.now = now; + self + } +} + +impl HostCredentials { + async fn fetch( + &self, + vendor: KeyVendor, + scope: &str, + ) -> Result>, CredentialError> { + self.import.credential(vendor, scope).await + } + + async fn aws(&self) -> Result { + let vendor = KeyVendor::Aws; + let bytes = self.fetch(vendor, "").await?; + let mut fields = CredentialFields::read(vendor, &bytes, (self.now)())?; + let id = fields.require(vendor, "access_key_id")?; + let secret = fields.require(vendor, "secret_access_key")?; + let session = fields.take("session_token"); + let remaining = fields.finish(vendor)?; + // The vendor type takes `String`s and wipes its own copies of the + // secret and the session token; ours wipe on drop here. + let credentials = stack_kms_aws::Credentials::new( + id.as_str(), + secret.as_str(), + session.as_ref().map(|token| token.as_str().to_owned()), + ); + Ok(match remaining { + Some(left) => credentials.with_expiry((self.now)() + left), + None => credentials, + }) + } + + async fn token( + &self, + vendor: KeyVendor, + scope: &str, + ) -> Result<(Zeroizing, Option), CredentialError> { + let bytes = self.fetch(vendor, scope).await?; + read_token(vendor, &bytes, (self.now)()) + } +} + +impl stack_kms_aws::CredentialsProvider for HostCredentials { + async fn credentials( + &self, + ) -> Result { + self.aws() + .await + .map_err(|e| stack_kms_aws::CredentialsError::new(e.kind, e.message)) + } +} + +impl stack_kms_vault::TokenProvider for HostCredentials { + async fn token(&self) -> Result { + let (token, remaining) = self + .token(KeyVendor::Vault, "") + .await + .map_err(|e| stack_kms_vault::AuthError::new(e.kind, e.message))?; + let token = stack_kms_vault::VaultToken::new(token.as_str()); + Ok(match remaining { + Some(left) => token.with_lease(left), + None => token, + }) + } +} + +impl stack_kms_gcp::TokenProvider for HostCredentials { + async fn token(&self) -> Result { + let (token, remaining) = self + .token(KeyVendor::Gcp, stack_kms_gcp::CLOUD_KMS_SCOPE) + .await + .map_err(|e| stack_kms_gcp::AuthError::new(e.kind, e.message))?; + let token = stack_kms_gcp::AccessToken::new(token.as_str()); + Ok(match remaining { + Some(left) => token.with_expires_in(left), + None => token, + }) + } +} + +impl stack_kms_azure::TokenProvider for HostCredentials { + async fn token( + &self, + scope: &str, + ) -> Result { + let (token, remaining) = self + .token(KeyVendor::Azure, scope) + .await + .map_err(|e| stack_kms_azure::AuthError::new(e.kind, e.message))?; + let token = stack_kms_azure::AccessToken::new(token.as_str()); + Ok(match remaining { + Some(left) => token.with_expires_in(left), + None => token, + }) + } +} + +/// Declare [`GuestVendor`] from one list of its vendor clients, and forward +/// every [`KeyVendorBackend`] method to the configured one. The list is the +/// only place the variants are named, so a new vendor client is one line +/// here (plus its config keys in [`crate::config`] and its constructor in +/// [`build`]). +macro_rules! guest_vendors { + ($($variant:ident($client:ty),)+) => { + /// The guest's one key vendor backend: whichever the host configured. + /// + /// One enum, not a type parameter, so that the instance's cipher has + /// one type whatever the host chose at run time. + pub enum GuestVendor { + $($variant($client),)+ + } + + impl KeyVendorBackend for GuestVendor + where + T: VendorTransport + Send + Sync, + C: CredentialImport, + { + fn vendor(&self) -> KeyVendor { + match self { $(Self::$variant(b) => b.vendor(),)+ } + } + + async fn wrap( + &self, + key_ref: &str, + key: &KeyBytes, + aad: &[u8], + ) -> Result, Error> { + match self { $(Self::$variant(b) => b.wrap(key_ref, key, aad).await,)+ } + } + + async fn unwrap( + &self, + key_ref: &str, + wrapped: &[u8], + aad: &[u8], + ) -> Result { + match self { $(Self::$variant(b) => b.unwrap(key_ref, wrapped, aad).await,)+ } + } + + async fn sleep(&self, duration: Duration) { + match self { $(Self::$variant(b) => b.sleep(duration).await,)+ } + } + } + }; +} + +guest_vendors! { + Aws(stack_kms_aws::AwsKms>), + Vault(stack_kms_vault::VaultTransit>), + Gcp(stack_kms_gcp::GcpKms>), + Azure(stack_kms_azure::AzureKeyVault>), + Static(StaticKeyVendor), +} + +/// Prints the vendor's name only: a client holds credentials or a key. +impl std::fmt::Debug for GuestVendor +where + T: VendorTransport + Send + Sync, + C: CredentialImport, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("GuestVendor") + .field(&self.vendor().name()) + .finish() + } +} + +/// Why the configured backend could not be built: a setting the vendor +/// client refused (`STATUS_ENCODING`, naming the config key), or a keyset +/// record ([`RecordError`]). +#[derive(Debug)] +pub enum BuildError { + /// The config key whose value the vendor client refused. + Setting(&'static str), + /// A keyset record was refused. + Record(RecordError), +} + +impl From for BuildError { + fn from(error: RecordError) -> Self { + Self::Record(error) + } +} + +/// Build the key source of the configured backend: the client over +/// `transport` and `import`, and the adapter over the keyset records. A +/// config with no backend has no key source to build: the caller routes +/// every request to ZeroKMS. +pub fn build( + config: VendorConfig, + transport: T, + import: C, +) -> Result>, BuildError> +where + T: VendorTransport + Send + Sync, + C: CredentialImport, +{ + let VendorConfig { + settings, + records, + default_keyset, + root_key_ttl, + } = config; + let credentials = HostCredentials::new(import); + let backend = match settings { + VendorSettings::Aws { region, endpoint } => { + let mut config = stack_kms_aws::AwsKmsConfig::new(region) + .map_err(|_| BuildError::Setting("aws_region"))?; + if let Some(endpoint) = endpoint { + config = config + .with_endpoint(endpoint) + .map_err(|_| BuildError::Setting("aws_endpoint"))?; + } + GuestVendor::Aws(stack_kms_aws::AwsKms::new(config, transport, credentials)) + } + VendorSettings::Vault { + address, + namespace, + mount, + } => { + let mut config = stack_kms_vault::VaultConfig::new(address) + .map_err(|_| BuildError::Setting("vault_address"))?; + if let Some(namespace) = namespace { + config = config + .with_namespace(namespace) + .map_err(|_| BuildError::Setting("vault_namespace"))?; + } + if let Some(mount) = mount { + config = config + .with_mount(mount) + .map_err(|_| BuildError::Setting("vault_mount"))?; + } + GuestVendor::Vault(stack_kms_vault::VaultTransit::new( + config, + transport, + credentials, + )) + } + VendorSettings::Gcp { endpoint } => { + let mut config = stack_kms_gcp::GcpKmsConfig::new(); + if let Some(endpoint) = endpoint { + config = config + .with_endpoint(endpoint) + .map_err(|_| BuildError::Setting("gcp_endpoint"))?; + } + GuestVendor::Gcp(stack_kms_gcp::GcpKms::new(config, transport, credentials)) + } + VendorSettings::Azure => GuestVendor::Azure(stack_kms_azure::AzureKeyVault::new( + stack_kms_azure::AzureKeyVaultConfig::new(), + transport, + credentials, + )), + VendorSettings::Static { key_ref, key } => { + GuestVendor::Static(StaticKeyVendor::new(key_ref, key)) + } + }; + let records = records + .iter() + .map(|text| KeysetRecord::parse(text)) + .collect::, _>>()?; + let default = match default_keyset { + None => None, + Some(DefaultKeyset::Id(id)) => Some(id), + Some(DefaultKeyset::Name(name)) => Some( + records + .iter() + .find(|r| r.name() == Some(name.as_str())) + .map(KeysetRecord::id) + .ok_or(RecordError::UnknownDefault)?, + ), + }; + let mut source = VendorKeySource::new(backend, records)?; + if let Some(id) = default { + source = source.with_default_keyset(id)?; + } + if let Some(ttl) = root_key_ttl { + source = source.with_root_key_ttl(ttl); + } + Ok(source) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + use crate::test_frame::{frame, obj, V}; + use stack_encrypt::Controlled as _; + use stack_kms_aws::CredentialsProvider; + use stack_kms_vendor::{TransportError, VendorRequest, VendorResponse}; + + /// A fixed clock: 2026-11-06T00:00:00Z. + fn now() -> SystemTime { + SystemTime::UNIX_EPOCH + Duration::from_secs(1_793_923_200) + } + + fn object(entries: Vec<(&'static str, V)>) -> V { + obj(entries) + } + + fn string(text: &str) -> V { + V::Str(text.into()) + } + + type Answer = Box Result + Send + Sync>; + + /// A fake import: answers each call with a new value from `answer` and + /// records what it was asked. + struct FakeImport { + answer: Answer, + asked: Mutex>, + } + + impl FakeImport { + fn new(answer: impl Fn() -> Result + Send + Sync + 'static) -> Self { + Self { + answer: Box::new(answer), + asked: Mutex::new(Vec::new()), + } + } + + fn unused() -> Self { + Self::new(|| Err(CredentialError::denied("unused"))) + } + } + + impl CredentialImport for FakeImport { + async fn credential( + &self, + vendor: KeyVendor, + scope: &str, + ) -> Result>, CredentialError> { + self.asked.lock().unwrap().push((vendor, scope.to_owned())); + (self.answer)().map(|value| Zeroizing::new(frame(&value))) + } + } + + fn aws_value() -> V { + object(vec![ + ("access_key_id", string("AKIDEXAMPLE")), + ("secret_access_key", string("fixture-secret")), + ]) + } + + fn block(f: F) -> F::Output { + futures::executor::block_on(f) + } + + #[test] + fn aws_credentials_are_read_from_the_host_value() { + let expires = now() + Duration::from_secs(900); + let seconds = expires + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs(); + let provider = HostCredentials::new(FakeImport::new(move || { + Ok(object(vec![ + ("access_key_id", string("AKIDEXAMPLE")), + ("secret_access_key", string("fixture-secret\n")), + ("session_token", string("fixture-session-token")), + ("expires_at", V::I64(seconds as i64)), + ])) + })) + .with_now(now); + let credentials = block(provider.credentials()).unwrap(); + assert_eq!(credentials.access_key_id(), "AKIDEXAMPLE"); + assert_eq!(credentials.secret_access_key(), "fixture-secret"); + assert_eq!(credentials.session_token(), Some("fixture-session-token")); + assert_eq!(credentials.expires_at(), Some(expires)); + assert_eq!( + provider.import.asked.lock().unwrap().as_slice(), + &[(KeyVendor::Aws, String::new())] + ); + } + + #[test] + fn a_missing_session_token_and_expiry_are_allowed() { + let provider = HostCredentials::new(FakeImport::new(|| Ok(aws_value()))); + let credentials = block(provider.credentials()).unwrap(); + assert_eq!(credentials.session_token(), None); + assert_eq!(credentials.expires_at(), None); + } + + #[test] + fn bad_credentials_are_refused_without_their_secret_in_the_message() { + fn with(extra: Vec<(&'static str, V)>) -> V { + let mut entries = vec![("access_key_id", string("AKIDEXAMPLE"))]; + entries.extend(extra); + object(entries) + } + fn expired() -> i64 { + now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 + } + type Case = (&'static str, fn() -> V); + let cases: Vec = vec![ + ("not an object", || string("fixture-secret")), + ("no secret", || with(vec![])), + ("empty secret", || { + with(vec![("secret_access_key", string(""))]) + }), + ("expired", || { + with(vec![ + ("secret_access_key", string("fixture-secret")), + ("expires_at", V::I64(expired())), + ]) + }), + ("negative expiry", || { + with(vec![ + ("secret_access_key", string("fixture-secret")), + ("expires_at", V::I64(-1)), + ]) + }), + ("control character", || { + with(vec![("secret_access_key", string("fixture\nsecret"))]) + }), + ("unknown key", || { + with(vec![ + ("secret_access_key", string("fixture-secret")), + ("token", string("fixture-secret")), + ]) + }), + ("not a string", || { + with(vec![("secret_access_key", V::I64(1))]) + }), + ]; + for (label, value) in cases { + let provider = HostCredentials::new(FakeImport::new(move || Ok(value()))).with_now(now); + let error = block(provider.aws()).expect_err(label); + assert_eq!(error.kind, BackendErrorKind::AccessDenied, "{label}"); + assert!( + !error.message.contains("fixture"), + "{label}: {}", + error.message + ); + } + } + + #[test] + fn a_host_failure_keeps_its_kind() { + let failure = CredentialError { + kind: BackendErrorKind::Unavailable, + message: "the host credential source failed".into(), + }; + let provider = HostCredentials::new(FakeImport::new(move || Err(failure.clone()))); + let error = block(provider.credentials()).unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::Unavailable); + } + + #[test] + fn token_providers_ask_with_their_vendor_and_scope() { + let provider = HostCredentials::new(FakeImport::new(|| { + Ok(object(vec![("token", string("fixture-token"))])) + })); + let token = block(stack_kms_vault::TokenProvider::token(&provider)).unwrap(); + assert_eq!(token.token(), "fixture-token"); + + let token = block(stack_kms_gcp::TokenProvider::token(&provider)).unwrap(); + assert_eq!(token.token(), "fixture-token"); + + let token = block(stack_kms_azure::TokenProvider::token( + &provider, + stack_kms_azure::MANAGED_HSM_SCOPE, + )) + .unwrap(); + assert_eq!(token.token(), "fixture-token"); + + assert_eq!( + provider.import.asked.lock().unwrap().as_slice(), + &[ + (KeyVendor::Vault, String::new()), + (KeyVendor::Gcp, stack_kms_gcp::CLOUD_KMS_SCOPE.to_owned()), + ( + KeyVendor::Azure, + stack_kms_azure::MANAGED_HSM_SCOPE.to_owned() + ), + ] + ); + + // An AWS credential where a token is wanted is refused. + let provider = HostCredentials::new(FakeImport::new(|| Ok(aws_value()))); + assert!(block(stack_kms_vault::TokenProvider::token(&provider)).is_err()); + } + + /// A transport that answers every request with one response and keeps + /// the requests. + #[derive(Default)] + struct Recording { + requests: Mutex>, + response: Mutex>, + } + + impl VendorTransport for &Recording { + async fn send(&self, request: VendorRequest) -> Result { + self.requests.lock().unwrap().push(request); + self.response + .lock() + .unwrap() + .take() + .ok_or_else(|| TransportError("no response".into())) + } + + async fn sleep(&self, _: Duration) {} + } + + fn aws_settings() -> VendorConfig { + VendorConfig { + settings: VendorSettings::Aws { + region: "us-east-1".into(), + endpoint: None, + }, + records: Vec::new(), + default_keyset: None, + root_key_ttl: None, + } + } + + #[test] + fn an_aws_request_is_signed_with_the_host_credentials() { + let transport = Recording::default(); + *transport.response.lock().unwrap() = Some(VendorResponse::new( + 200, + br#"{"KeyId":"arn:aws:kms:us-east-1:111122223333:key/k","Plaintext":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}"#.to_vec(), + )); + let import = FakeImport::new(|| { + Ok(object(vec![ + ("access_key_id", string("AKIDEXAMPLE")), + ("secret_access_key", string("fixture-secret")), + ("session_token", string("fixture-session-token")), + ])) + }); + let source = build(aws_settings(), &transport, import).unwrap(); + let key = block(source.backend().unwrap( + "arn:aws:kms:us-east-1:111122223333:key/k", + b"wrapped", + b"aad", + )) + .unwrap(); + assert_eq!(key.risky_ref(), &[0u8; 32]); + + let requests = transport.requests.lock().unwrap(); + let request = &requests[0]; + assert_eq!(request.url, "https://kms.us-east-1.amazonaws.com/"); + let authorization = request.header_value("authorization").unwrap(); + assert!(authorization.starts_with("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/")); + assert_eq!( + request.header_value("x-amz-security-token"), + Some("fixture-session-token") + ); + assert_eq!( + request.header_value("x-amz-target"), + Some("TrentService.Decrypt") + ); + } + + /// The guest vendor reports the vendor client it holds. + #[test] + fn the_guest_vendor_forwards_to_its_client() { + let config = VendorConfig { + settings: VendorSettings::Gcp { endpoint: None }, + records: Vec::new(), + default_keyset: None, + root_key_ttl: None, + }; + let transport = Recording::default(); + let source = build(config, &transport, FakeImport::unused()).unwrap(); + let backend = source.backend(); + assert_eq!(backend.vendor(), KeyVendor::Gcp); + assert_eq!(format!("{backend:?}"), "GuestVendor(\"gcp-kms\")"); + } + + #[test] + fn a_bad_setting_names_its_config_key() { + let config = VendorConfig { + settings: VendorSettings::Aws { + region: "us east 1".into(), + endpoint: None, + }, + records: Vec::new(), + default_keyset: None, + root_key_ttl: None, + }; + let error = build(config, &Recording::default(), FakeImport::unused()).unwrap_err(); + assert!(matches!(error, BuildError::Setting("aws_region"))); + + let config = VendorConfig { + settings: VendorSettings::Vault { + address: "not a url".into(), + namespace: None, + mount: None, + }, + records: Vec::new(), + default_keyset: None, + root_key_ttl: None, + }; + let error = build(config, &Recording::default(), FakeImport::unused()).unwrap_err(); + assert!(matches!(error, BuildError::Setting("vault_address"))); + } + + #[test] + fn a_record_of_another_vendor_is_refused() { + // The pinned AWS record of stack-kms-vendor's own tests. + let aws = "stack-keyset:AQECAQIDBAUGBwgJCgsMDQ4PEARhY21lAChhcm46YXdzOmttczp1cy1lYXN0LTE6MTExMTIyMjIzMzMzOmtleS9rAAMBAgM"; + let config = VendorConfig { + settings: VendorSettings::Static { + key_ref: "local".into(), + key: KeyBytes::new([7; 32]), + }, + records: vec![aws.to_owned()], + default_keyset: None, + root_key_ttl: None, + }; + let error = build(config, &Recording::default(), FakeImport::unused()).unwrap_err(); + assert!(matches!( + error, + BuildError::Record(RecordError::ForeignVendor { .. }) + )); + + let config = VendorConfig { + records: vec!["stack-keyset:***".to_owned()], + ..aws_settings() + }; + assert!(matches!( + build(config, &Recording::default(), FakeImport::unused(),), + Err(BuildError::Record(_)) + )); + } +} diff --git a/languages/golang/encrypt/guest/src/zerokms.rs b/languages/golang/encrypt/guest/src/zerokms.rs new file mode 100644 index 0000000000..1b5535156a --- /dev/null +++ b/languages/golang/encrypt/guest/src/zerokms.rs @@ -0,0 +1,140 @@ +//! The ZeroKMS side of the instance's key router, which a client with a key +//! vendor backend may leave out (CIP-5311). +//! +//! A client is ZeroKMS and at most one key vendor backend +//! (`stack_kms_vendor::KeyRouter`). A key vendor backend is an alternative to +//! ZeroKMS, so a client may also have the vendor alone: the host gives no +//! client id and no client key, and the router's ZeroKMS side is +//! [`MaybeZeroKms::Absent`]. The router then sends the default keyset to the +//! vendor (`KeyRouter::with_vendor_default`), and anything it still sends +//! here (a keyset no record names, or a leaf of such a keyset) is refused with +//! [`no_zerokms`], which the status layer reports as `STATUS_NO_ZEROKMS`. + +use std::borrow::Cow; + +use stack_kms::{ + BackendErrorKind, DataKey, DataKeySource, DataKeyWithTag, Error, GenerateKeyPayload, + IdentifiedBy, IndexKey, IndexKeySource, KeyBackend, RetrieveKeyPayload, UnverifiedContext, +}; +use uuid::Uuid; + +/// The ZeroKMS client, or none. +pub enum MaybeZeroKms { + /// The client has ZeroKMS. + Present(Z), + /// The client has a key vendor backend and no ZeroKMS. + Absent, +} + +// MaybeZeroKms stands for ZeroKMS (present or not), never for a router. +impl stack_kms::ZeroKmsKeySource for MaybeZeroKms {} + +/// The cause of [`no_zerokms`]: its type is what the status layer finds to +/// report `STATUS_NO_ZEROKMS`. +#[derive(Debug, Clone, Copy)] +pub struct NoZeroKms; + +impl std::fmt::Display for NoZeroKms { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str( + "this client has no ZeroKMS, only a key vendor backend, and no keyset record names this keyset", + ) + } +} + +impl std::error::Error for NoZeroKms {} + +/// The refusal of every request that reaches [`MaybeZeroKms::Absent`]. +pub fn no_zerokms() -> Error { + Error::Backend { + backend: KeyBackend::ZeroKms, + kind: BackendErrorKind::NotFound, + source: Box::new(NoZeroKms), + } +} + +/// Whether `error` is [`no_zerokms`]. +pub fn is_no_zerokms(error: &Error) -> bool { + matches!(error, Error::Backend { source, .. } if source.is::()) +} + +impl DataKeySource for MaybeZeroKms { + async fn generate_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option>, + ) -> Result, Error> { + match self { + Self::Present(z) => { + z.generate_keys(payloads, keyset_id, unverified_context) + .await + } + Self::Absent => Err(no_zerokms()), + } + } + + async fn retrieve_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option<&UnverifiedContext>, + ) -> Result, Error> { + match self { + Self::Present(z) => { + z.retrieve_keys(payloads, keyset_id, unverified_context) + .await + } + Self::Absent => Err(no_zerokms()), + } + } +} + +impl IndexKeySource for MaybeZeroKms { + async fn load_index_key( + &self, + keyset_id: Option, + ) -> Result<(Uuid, IndexKey), Error> { + match self { + Self::Present(z) => z.load_index_key(keyset_id).await, + Self::Absent => Err(no_zerokms()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stack_kms::FakeDataKeySource; + + fn block(f: F) -> F::Output { + futures::executor::block_on(f) + } + + #[test] + fn absent_refuses_every_request_with_the_typed_error() { + let absent = MaybeZeroKms::::Absent; + let id = Uuid::from_u128(1); + for error in [ + block(absent.generate_keys(vec![], Some(id), None)).unwrap_err(), + block(absent.retrieve_keys(vec![], Some(id), None)).unwrap_err(), + block(absent.load_index_key(None)).unwrap_err(), + ] { + assert!(is_no_zerokms(&error), "{error:?}"); + } + // Another backend error is not this one. + assert!(!is_no_zerokms(&Error::Backend { + backend: KeyBackend::ZeroKms, + kind: BackendErrorKind::NotFound, + source: "other".into(), + })); + } + + #[test] + fn present_forwards() { + let present = MaybeZeroKms::Present(FakeDataKeySource::new()); + let (id, _) = block(present.load_index_key(None)).unwrap(); + let (again, _) = block(present.load_index_key(Some(IdentifiedBy::Uuid(id)))).unwrap(); + assert_eq!(again, id); + } +} diff --git a/languages/golang/encrypt/keyvendor.go b/languages/golang/encrypt/keyvendor.go new file mode 100644 index 0000000000..4894e89916 --- /dev/null +++ b/languages/golang/encrypt/keyvendor.go @@ -0,0 +1,579 @@ +package encrypt + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "time" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" + "golang.org/x/oauth2" +) + +// KeyVendor is the one key vendor backend a [Client] can hold beside +// ZeroKMS: [AWSKMS], [GoogleCloudKMS], [AzureKeyVault], [VaultTransit] or +// [StaticKey]. Give it with [WithKeyVendor], and its keyset records with +// [WithKeysetRecords]. +// +// A keyset whose record the client holds is sealed and opened through the +// key vendor backend. Every other keyset, and the default keyset, is +// ZeroKMS's. Select a vendor keyset by the name or the id in its record: +// client.Keyset(encrypt.KeysetName("payments")). +// +// The guest makes the vendor requests and signs them. The client's +// RoundTripper ([WithTransport]) sends them, so the host sees the keys that +// those requests and responses carry in plaintext. See the package +// documentation for what crosses to the host, and for what each backend +// can and cannot do. +// +// The interface is sealed: the five types above are its only +// implementations. +type KeyVendor interface { + keyVendor() (*vendorSpec, error) +} + +// vendorSpec is a KeyVendor as the client uses it: the config keys the +// guest reads, and where the per-request credential comes from. +type vendorSpec struct { + // vendor is the backend. Its byte is what the guest names in a + // credential request; its name is the guest's key_vendor value. + vendor keyVendorID + // fields are the backend's settings in the guest config. + fields vcvalue.Object + // staticKey is StaticKey's key, consumed by NewClient. + staticKey []byte + aws AWSCredentialSource + token AccessTokenSource +} + +// keyVendorID is a KeyVendor byte of the keyset +// record. It crosses in the guest's credential requests (step.go). +type keyVendorID uint32 + +// The KeyVendor bytes. They are a storage commitment, as in the Rust crate +// stack-kms (KeyVendor::byte). +const ( + vendorAWS keyVendorID = 1 + vendorGCP keyVendorID = 2 + vendorAzure keyVendorID = 3 + vendorVault keyVendorID = 4 + vendorStatic keyVendorID = 5 +) + +// keyVendorNames is the one table of the vendor names on the Go side: the +// guest's key_vendor value of each KeyVendor byte. The guest reads the name +// with KeyVendor::from_name of the Rust crate stack-kms, so these strings +// are a wire format, not a choice. TestKeyVendorNamesMatchTheGuest pins +// each one to the guest's name for the same byte. +var keyVendorNames = map[keyVendorID]string{ + vendorAWS: "aws-kms", + vendorGCP: "gcp-kms", + vendorAzure: "azure-key-vault", + vendorVault: "vault-transit", + vendorStatic: "static", +} + +// String is the vendor's name, as the guest spells it. +func (id keyVendorID) String() string { + if name, ok := keyVendorNames[id]; ok { + return name + } + return fmt.Sprintf("key vendor %d", uint32(id)) +} + +// AWSKMS is AWS KMS as the key vendor backend. The key in each keyset +// record is a KMS key ARN. +type AWSKMS struct { + // Region is the AWS region of the keys, such as "us-east-1". Required. + Region string + // Endpoint overrides the regional AWS KMS endpoint, for a VPC endpoint + // or LocalStack. Empty means https://kms..amazonaws.com/. + Endpoint string + // Credentials supplies temporary AWS credentials, asked before each + // request. Required. Adapt the AWS SDK's chain with + // [AWSCredentialsFunc]; [AWSCredentialsFromEnv] reads the environment. + Credentials AWSCredentialSource +} + +func (v AWSKMS) keyVendor() (*vendorSpec, error) { + if v.Region == "" { + return nil, fmt.Errorf("%w: AWSKMS needs a Region", ErrEncoding) + } + if v.Credentials == nil { + return nil, fmt.Errorf("%w: AWSKMS needs Credentials", ErrEncoding) + } + fields := vcvalue.Object{{Key: "aws_region", Value: v.Region}} + if v.Endpoint != "" { + fields = append(fields, vcvalue.Field{Key: "aws_endpoint", Value: v.Endpoint}) + } + return &vendorSpec{vendor: vendorAWS, fields: fields, aws: v.Credentials}, nil +} + +// GoogleCloudKMS is Google Cloud KMS as the key vendor backend. The key in +// each keyset record is a crypto key name +// (projects/…/locations/…/keyRings/…/cryptoKeys/…). +type GoogleCloudKMS struct { + // Endpoint overrides https://cloudkms.googleapis.com, for a regional + // endpoint. Empty means the global one. + Endpoint string + // Token supplies an OAuth 2.0 access token with the Cloud KMS scope, + // asked before each request. Required. [OAuth2AccessTokens] adapts + // golang.org/x/oauth2/google's default credentials. + Token AccessTokenSource +} + +func (v GoogleCloudKMS) keyVendor() (*vendorSpec, error) { + if v.Token == nil { + return nil, fmt.Errorf("%w: GoogleCloudKMS needs a Token source", ErrEncoding) + } + var fields vcvalue.Object + if v.Endpoint != "" { + fields = append(fields, vcvalue.Field{Key: "gcp_endpoint", Value: v.Endpoint}) + } + return &vendorSpec{vendor: vendorGCP, fields: fields, token: v.Token}, nil +} + +// AzureKeyVault is Azure Key Vault or Managed HSM as the key vendor +// backend. The key in each keyset record is a key identifier +// (https://.vault.azure.net/keys/), which names the vault. +type AzureKeyVault struct { + // Token supplies a Microsoft Entra ID access token for the scope the + // client asks for: https://vault.azure.net/.default for Key Vault, + // https://managedhsm.azure.net/.default for Managed HSM. Required. + // Adapt azidentity with [AccessTokenFunc]. + Token AccessTokenSource +} + +func (v AzureKeyVault) keyVendor() (*vendorSpec, error) { + if v.Token == nil { + return nil, fmt.Errorf("%w: AzureKeyVault needs a Token source", ErrEncoding) + } + return &vendorSpec{vendor: vendorAzure, token: v.Token}, nil +} + +// VaultTransit is HashiCorp Vault's Transit secrets engine as the key +// vendor backend. The key in each keyset record is a Transit key name. +type VaultTransit struct { + // Address is the Vault address, such as "https://vault.example.com:8200". + // Required. + Address string + // Namespace is the Vault Enterprise namespace. Empty means none. + Namespace string + // Mount is the Transit mount path. Empty means "transit". + Mount string + // Token supplies a Vault client token, asked before each request; the + // scope it is given is empty. Required. Adapt the Vault API client's + // token with [AccessTokenFunc]. + Token AccessTokenSource +} + +func (v VaultTransit) keyVendor() (*vendorSpec, error) { + if v.Address == "" { + return nil, fmt.Errorf("%w: VaultTransit needs an Address", ErrEncoding) + } + if v.Token == nil { + return nil, fmt.Errorf("%w: VaultTransit needs a Token source", ErrEncoding) + } + fields := vcvalue.Object{{Key: "vault_address", Value: v.Address}} + if v.Namespace != "" { + fields = append(fields, vcvalue.Field{Key: "vault_namespace", Value: v.Namespace}) + } + if v.Mount != "" { + fields = append(fields, vcvalue.Field{Key: "vault_mount", Value: v.Mount}) + } + return &vendorSpec{vendor: vendorVault, fields: fields, token: v.Token}, nil +} + +// StaticKey is a 32-byte key the application holds, as the key vendor +// backend: for development, tests, and deployments that keep a key in their +// own secret store. Derived data keys only, because the key is always in +// memory. Nothing outside the process can refuse or audit its use. The key +// crosses from the host to the guest once, when NewClient starts. +type StaticKey struct { + // KeyRef is the key's reference, as the keyset records name it. + // Required. + KeyRef string + // Key is the key: 32 bytes. NewClient wipes it whatever the outcome, + // as it consumes the client key: after NewClient returns, the slice + // holds zeros. + Key []byte +} + +// String hides the key. +func (v StaticKey) String() string { + return fmt.Sprintf("encrypt.StaticKey{KeyRef: %s, Key: }", v.KeyRef) +} + +// GoString hides it from %#v too. +func (v StaticKey) GoString() string { return v.String() } + +// LogValue hides it from slog. +func (v StaticKey) LogValue() slog.Value { + return slog.GroupValue(slog.String("key_ref", v.KeyRef)) +} + +func (v StaticKey) keyVendor() (*vendorSpec, error) { + if v.KeyRef == "" { + return nil, fmt.Errorf("%w: StaticKey needs a KeyRef", ErrEncoding) + } + if len(v.Key) != 32 { + return nil, fmt.Errorf("%w: StaticKey's Key is %d bytes, not 32", ErrEncoding, len(v.Key)) + } + return &vendorSpec{ + vendor: vendorStatic, + fields: vcvalue.Object{{Key: "static_key_ref", Value: v.KeyRef}}, + staticKey: v.Key, + }, nil +} + +// wipeVendorKey zeroes a StaticKey's key, the one key material a KeyVendor +// value holds. Any other KeyVendor holds none. +func wipeVendorKey(v KeyVendor) { + switch k := v.(type) { + case StaticKey: + wipe(k.Key) + case *StaticKey: + if k != nil { + wipe(k.Key) + } + } +} + +// WithKeyVendor gives the client one key vendor backend. It needs +// [WithKeysetRecords]; one without the other is refused. A later +// WithKeyVendor replaces an earlier one. The default is none: every keyset +// is ZeroKMS's, as before. +// +// A key vendor backend is an alternative to ZeroKMS. With no +// [WithCredentials] the client has no ZeroKMS: NewClient neither needs nor +// looks for ZeroKMS credentials, the default keyset is a vendor keyset (see +// [WithDefaultKeyset]), and a call that needs a ZeroKMS keyset fails with +// [ErrNoZeroKMS]. To hold both, also give WithCredentials, for example +// WithCredentials(AutoCredentials()). +func WithKeyVendor(v KeyVendor) ClientOption { + return func(o *clientOptions) { + if o.keyVendor != nil { + o.supersededVendors = append(o.supersededVendors, o.keyVendor) + } + o.keyVendor = v + } +} + +// WithKeysetRecords gives the keyset records of the client's key vendor +// backend: the "stack-keyset:…" texts that `stash keyset` writes, one per +// keyset. Each record names its keyset id, its name, the vendor key, its key +// mode and its wrapped root key; none holds a key in the clear. A record +// that does not parse, or names a key mode this release does not know, is +// refused with [ErrEncoding]. Every record must be of the backend +// [WithKeyVendor] gives, and two records may not share an id or a name. Records add up across calls. +func WithKeysetRecords(records ...string) ClientOption { + return func(o *clientOptions) { o.keysetRecords = append(o.keysetRecords, records...) } +} + +// WithDefaultKeyset names the default keyset of a client that has a key +// vendor backend and no ZeroKMS: the keyset [Client.DefaultKeyset] and every +// call that names no keyset use. Name it by the name or the id in its keyset +// record, as [Client.Keyset] does: [KeysetName] or [KeysetID]. With one +// keyset record it is that record's keyset and this option is not needed; +// with several and no default, NewClient fails with [ErrNotFound]. +// +// A client with ZeroKMS has the default keyset its ZeroKMS administrator +// set, and refuses this option with [ErrEncoding]. +func WithDefaultKeyset(sel KeysetSelector) ClientOption { + return func(o *clientOptions) { o.defaultKeyset, o.defaultKeysetSet = sel, true } +} + +// WithRootKeyTTL sets how long the guest keeps a keyset's root key +// unwrapped, after which the next use unwraps it again: one vendor call, +// which the vendor can refuse. Zero keeps none: each call that needs a +// data key unwraps it. The default is 5 minutes. The index key a keyset's +// search terms come from is kept in the client's keyset cache, not for this +// TTL. +// Negative, or with no [WithKeyVendor], is refused with [ErrEncoding]. +func WithRootKeyTTL(d time.Duration) ClientOption { + return func(o *clientOptions) { o.rootKeyTTL = &d } +} + +// checkVendorOnlyOptions refuses the options that need a key vendor +// backend, or a client with no ZeroKMS, when the client has not got one. +func checkVendorOnlyOptions(cfg *clientOptions, vendorOnly bool) error { + if cfg.rootKeyTTL != nil { + if cfg.keyVendor == nil { + return fmt.Errorf("%w: WithRootKeyTTL needs WithKeyVendor", ErrEncoding) + } + if *cfg.rootKeyTTL < 0 { + return fmt.Errorf("%w: WithRootKeyTTL must not be negative", ErrEncoding) + } + } + if !cfg.defaultKeysetSet { + return nil + } + if !vendorOnly { + return fmt.Errorf("%w: WithDefaultKeyset needs WithKeyVendor and no WithCredentials: a client with ZeroKMS has the default keyset its administrator set", ErrEncoding) + } + switch cfg.defaultKeyset.(type) { + case KeysetName, KeysetID: + return nil + default: + return fmt.Errorf("%w: WithDefaultKeyset takes a KeysetName or a KeysetID", ErrEncoding) + } +} + +// noZeroKMS is the Credentials of a client that has a key vendor backend +// and no ZeroKMS: nothing to resolve, no client key and no token. +type noZeroKMS struct{} + +func (noZeroKMS) resolve(context.Context, resolveOptions) (*resolvedCredentials, error) { + return &resolvedCredentials{}, nil +} + +// keyVendorConfig checks the key vendor options host-side, before anything +// is resolved, and renders them. Nil when no backend is configured. +func keyVendorConfig(v KeyVendor, records []string) (*vendorSpec, error) { + if v == nil { + if len(records) > 0 { + return nil, fmt.Errorf("%w: WithKeysetRecords needs WithKeyVendor", ErrEncoding) + } + return nil, nil + } + spec, err := v.keyVendor() + if err != nil { + return nil, err + } + if len(records) == 0 { + return nil, fmt.Errorf("%w: WithKeyVendor needs WithKeysetRecords", ErrEncoding) + } + return spec, nil +} + +// configFields renders the backend and its records as guest config keys. +// The result holds a StaticKey's key: the caller wipes what it encodes. +func (s *vendorSpec) configFields(records []string) vcvalue.Object { + fields := vcvalue.Object{ + {Key: "key_vendor", Value: s.vendor.String()}, + {Key: "keyset_records", Value: append([]string(nil), records...)}, + } + fields = append(fields, s.fields...) + if s.staticKey != nil { + fields = append(fields, vcvalue.Field{Key: "static_key", Value: s.staticKey}) + } + return fields +} + +// ErrNoKeyVendorCredentials is a credential source with nothing to give, +// such as [AWSCredentialsFromEnv] with the variables unset. The wrapped +// error says what to set. +var ErrNoKeyVendorCredentials = errors.New("encrypt: no key vendor credentials") + +// AWSCredentials are temporary AWS credentials: what the guest signs an +// AWS KMS request with (SigV4). Long-term access keys work too, with no +// SessionToken, but prefer the temporary credentials of a role. +type AWSCredentials struct { + AccessKeyID string + SecretAccessKey string + // SessionToken is the session token of temporary credentials. + SessionToken string + // Expires is when the credentials expire. Zero means unknown; the guest + // refuses credentials whose expiry has passed. + Expires time.Time +} + +// String hides the secret and the session token. +func (c AWSCredentials) String() string { + return fmt.Sprintf("encrypt.AWSCredentials{AccessKeyID: %s, SecretAccessKey: , SessionToken: , Expires: %s}", c.AccessKeyID, c.Expires) +} + +// GoString hides them from %#v too. +func (c AWSCredentials) GoString() string { return c.String() } + +// LogValue hides them from slog. +func (c AWSCredentials) LogValue() slog.Value { + return slog.GroupValue(slog.String("access_key_id", c.AccessKeyID), slog.Time("expires", c.Expires)) +} + +// AWSCredentialSource supplies [AWSCredentials]. The client asks before +// each AWS KMS request, so a source that fetches should cache, as the AWS +// SDK's chain does. It is called with the client's lock held; it must not +// call the client. +type AWSCredentialSource interface { + AWSCredentials(ctx context.Context) (AWSCredentials, error) +} + +// AWSCredentialsFunc adapts a function to an [AWSCredentialSource]. With +// the AWS SDK for Go v2 (github.com/aws/aws-sdk-go-v2/config), whose +// credentials cache refreshes before expiry: +// +// cfg, err := config.LoadDefaultConfig(ctx) +// // ... +// creds := encrypt.AWSCredentialsFunc(func(ctx context.Context) (encrypt.AWSCredentials, error) { +// c, err := cfg.Credentials.Retrieve(ctx) +// return encrypt.AWSCredentials{ +// AccessKeyID: c.AccessKeyID, SecretAccessKey: c.SecretAccessKey, +// SessionToken: c.SessionToken, Expires: c.Expires, +// }, err +// }) +type AWSCredentialsFunc func(ctx context.Context) (AWSCredentials, error) + +// AWSCredentials calls f. +func (f AWSCredentialsFunc) AWSCredentials(ctx context.Context) (AWSCredentials, error) { + return f(ctx) +} + +// AWSCredentialsFromEnv reads AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and +// AWS_SESSION_TOKEN on each request. Without the first two it fails with +// [ErrNoKeyVendorCredentials]. For anything else (a role, IRSA, IMDS, SSO) +// use the AWS SDK's chain through [AWSCredentialsFunc]. +// +// With no AWS_SESSION_TOKEN these are usually the long-lived keys of an IAM +// user, and they cross to the guest on each request. Prefer the temporary +// credentials of a role, through [AWSCredentialsFunc]. +func AWSCredentialsFromEnv() AWSCredentialSource { + return AWSCredentialsFunc(func(context.Context) (AWSCredentials, error) { + c := AWSCredentials{ + AccessKeyID: os.Getenv("AWS_ACCESS_KEY_ID"), + SecretAccessKey: os.Getenv("AWS_SECRET_ACCESS_KEY"), + SessionToken: os.Getenv("AWS_SESSION_TOKEN"), + } + if c.AccessKeyID == "" || c.SecretAccessKey == "" { + return AWSCredentials{}, fmt.Errorf("%w: set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY", ErrNoKeyVendorCredentials) + } + return c, nil + }) +} + +// AccessToken is a short-lived bearer token for a key vendor backend: a +// Google OAuth 2.0 access token, a Microsoft Entra ID access token, or a +// Vault client token. +type AccessToken struct { + Token string + // Expires is when the token expires. Zero means unknown; the guest + // refuses a token whose expiry has passed. + Expires time.Time +} + +// String hides the token. +func (t AccessToken) String() string { + return fmt.Sprintf("encrypt.AccessToken{Token: , Expires: %s}", t.Expires) +} + +// GoString hides it from %#v too. +func (t AccessToken) GoString() string { return t.String() } + +// LogValue hides it from slog. +func (t AccessToken) LogValue() slog.Value { + return slog.GroupValue(slog.Time("expires", t.Expires)) +} + +// AccessTokenSource supplies an [AccessToken] for scope. The client asks +// before each vendor request, so a source that fetches should cache. Azure +// asks for the scope of the vault the key is in; Google Cloud asks for +// https://www.googleapis.com/auth/cloudkms; Vault asks with an empty scope. +// It is called with the client's lock held; it must not call the client. +type AccessTokenSource interface { + AccessToken(ctx context.Context, scope string) (AccessToken, error) +} + +// AccessTokenFunc adapts a function to an [AccessTokenSource]. With azidentity +// (github.com/Azure/azure-sdk-for-go/sdk/azidentity), whose credentials +// cache tokens: +// +// cred, err := azidentity.NewDefaultAzureCredential(nil) +// // ... +// tokens := encrypt.AccessTokenFunc(func(ctx context.Context, scope string) (encrypt.AccessToken, error) { +// t, err := cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{scope}}) +// return encrypt.AccessToken{Token: t.Token, Expires: t.ExpiresOn}, err +// }) +// +// With the Vault API client (github.com/hashicorp/vault/api), after a +// login method has set its token: +// +// tokens := encrypt.AccessTokenFunc(func(context.Context, string) (encrypt.AccessToken, error) { +// return encrypt.AccessToken{Token: vault.Token()}, nil +// }) +type AccessTokenFunc func(ctx context.Context, scope string) (AccessToken, error) + +// AccessToken calls f. +func (f AccessTokenFunc) AccessToken(ctx context.Context, scope string) (AccessToken, error) { + return f(ctx, scope) +} + +// OAuth2AccessTokens adapts a golang.org/x/oauth2 token source, such as +// Google's default credentials, to an [AccessTokenSource]. The scope is +// the source's own, set when it was made, so the scope asked for is not +// passed on: +// +// creds, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/cloudkms") +// // ... +// tokens := encrypt.OAuth2AccessTokens(creds.TokenSource) +// +// Wrap a source that fetches in oauth2.ReuseTokenSource, as +// google.FindDefaultCredentials does, so that each request does not fetch. +func OAuth2AccessTokens(source oauth2.TokenSource) AccessTokenSource { + return AccessTokenFunc(func(context.Context, string) (AccessToken, error) { + if source == nil { + return AccessToken{}, errors.New("encrypt: nil oauth2 token source") + } + t, err := source.Token() + if err != nil { + return AccessToken{}, err + } + return AccessToken{Token: t.AccessToken, Expires: t.Expiry}, nil + }) +} + +// StaticAccessToken is a source that always gives token, with no expiry: +// for a token from `gcloud auth print-access-token`, a Vault token in +// VAULT_TOKEN, or a test. It is used until the vendor refuses it. +func StaticAccessToken(token string) AccessTokenSource { + return AccessTokenFunc(func(context.Context, string) (AccessToken, error) { + return AccessToken{Token: token}, nil + }) +} + +// vendorCredential asks the backend's source for the credential the guest +// asked for, and renders it as the guest reads it. The result holds a +// secret; the caller wipes it. +func (s *vendorSpec) vendorCredential(ctx context.Context, vendor uint32, scope string) ([]byte, error) { + if keyVendorID(vendor) != s.vendor { + return nil, fmt.Errorf("encrypt: the guest asked for a credential of key vendor %d, but the client's is %s", vendor, s.vendor) + } + var fields vcvalue.Object + var expires time.Time + switch { + case s.aws != nil: + c, err := s.aws.AWSCredentials(ctx) + if err != nil { + return nil, err + } + if c.AccessKeyID == "" || c.SecretAccessKey == "" { + return nil, fmt.Errorf("%w: the AWS credential source returned no access key id or secret", ErrNoKeyVendorCredentials) + } + fields = vcvalue.Object{ + {Key: "access_key_id", Value: c.AccessKeyID}, + {Key: "secret_access_key", Value: c.SecretAccessKey}, + } + if c.SessionToken != "" { + fields = append(fields, vcvalue.Field{Key: "session_token", Value: c.SessionToken}) + } + expires = c.Expires + case s.token != nil: + t, err := s.token.AccessToken(ctx, scope) + if err != nil { + return nil, err + } + if t.Token == "" { + return nil, fmt.Errorf("%w: the %s token source returned an empty token", ErrNoKeyVendorCredentials, s.vendor) + } + fields = vcvalue.Object{{Key: "token", Value: t.Token}} + expires = t.Expires + default: + return nil, fmt.Errorf("encrypt: the %s key backend takes no credentials", s.vendor) + } + if !expires.IsZero() { + fields = append(fields, vcvalue.Field{Key: "expires_at", Value: expires.Unix()}) + } + return vcffi.Marshal(fields) +} diff --git a/languages/golang/encrypt/keyvendor_test.go b/languages/golang/encrypt/keyvendor_test.go new file mode 100644 index 0000000000..baf3364e9a --- /dev/null +++ b/languages/golang/encrypt/keyvendor_test.go @@ -0,0 +1,666 @@ +package encrypt_test + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/internal/testusers" +) + +// Hermetic tests of a client with a key vendor backend (CIP-5311): the +// deterministic-kms test build stands in for ZeroKMS, the backend is the +// static key or AWS KMS behind a fake RoundTripper, and its credentials +// come back through the real step loop. + +// The key vendor and key mode bytes of the keyset record (CIP-5306). +const ( + recordAWS = 1 + recordStatic = 5 + // A mode byte that names no mode. + modeUnknown = 1 + modeDerived = 2 +) + +// frame is stack-kms-vendor's framing: the piece count, then each piece's +// length and bytes, all lengths u64 little-endian. Written again here, so a +// record this test builds is a second implementation's check of the format. +func frame(pieces ...[]byte) []byte { + out := binary.LittleEndian.AppendUint64(nil, uint64(len(pieces))) + for _, p := range pieces { + out = binary.LittleEndian.AppendUint64(out, uint64(len(p))) + out = append(out, p...) + } + return out +} + +// recordKeyAAD is the associated data a keyset record's key is wrapped +// under. +func recordKeyAAD(id [16]byte, vendor, mode byte) []byte { + return frame([]byte("stack-kms-vendor/keyset-record-key/v1"), []byte("root-key"), id[:], []byte{vendor}, []byte{mode}) +} + +// keysetRecord renders a keyset record's text form. +func keysetRecord(id [16]byte, vendor, mode byte, name, keyRef string, wrapped []byte) string { + b := []byte{1, vendor, mode} + b = append(b, id[:]...) + b = append(b, byte(len(name))) + b = append(b, name...) + b = binary.BigEndian.AppendUint16(b, uint16(len(keyRef))) + b = append(b, keyRef...) + b = binary.BigEndian.AppendUint16(b, uint16(len(wrapped))) + b = append(b, wrapped...) + return "stack-keyset:" + base64.RawURLEncoding.EncodeToString(b) +} + +// staticWrap wraps key as the static key backend does: version 1, a 12-byte +// nonce, then AES-256-GCM under staticKey with aad. +func staticWrap(t *testing.T, staticKey, key, aad []byte) []byte { + t.Helper() + block, err := aes.NewCipher(staticKey) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + t.Fatal(err) + } + return append(append([]byte{1}, nonce...), gcm.Seal(nil, nonce, key, aad)...) +} + +func randomID(t *testing.T) [16]byte { + t.Helper() + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { + t.Fatal(err) + } + return id +} + +// vendorClient is a deterministic client with v, or a skip when the test +// build is absent. +func vendorClient(t *testing.T, rt http.RoundTripper, v encrypt.KeyVendor, records ...string) *encrypt.Client { + t.Helper() + c, err := encrypt.NewDeterministicVendorClient(context.Background(), testSeed, rt, v, records...) + if errors.Is(err, encrypt.ErrDeterministicGuestNotBuilt) { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + return c +} + +// roundTrip seals people under cipher and opens them through cipher and the +// client, and returns what was sealed. +func roundTrip(t *testing.T, c *encrypt.Client, cipher *encrypt.Cipher) []testusers.EncryptedUser { + t.Helper() + ctx := context.Background() + encrypted, err := testusers.Encrypt(ctx, cipher, people) + if err != nil { + t.Fatal(err) + } + want := append([]testusers.User(nil), people...) + want[0].Internal = "" + for _, d := range []encrypt.Decrypter{cipher, c} { + back, err := testusers.Decrypt(ctx, d, encrypted) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(back, want) { + t.Fatalf("round trip: got %+v", back) + } + } + return encrypted +} + +func TestStaticKeyVendorRoundTrip(t *testing.T) { + staticKey := bytes.Repeat([]byte{0x42}, 32) + rootKey := bytes.Repeat([]byte{0x07}, 32) + id := randomID(t) + record := keysetRecord(id, recordStatic, modeDerived, "local", "dev-key", staticWrap(t, staticKey, rootKey, recordKeyAAD(id, recordStatic, modeDerived))) + + key := append([]byte(nil), staticKey...) + c := vendorClient(t, refusing{}, encrypt.StaticKey{KeyRef: "dev-key", Key: key}, record) + if !bytes.Equal(key, make([]byte, 32)) { + t.Error("the static key was not wiped") + } + ctx := context.Background() + + vendor := c.Keyset(encrypt.KeysetName("local")) + got, err := vendor.KeysetID(ctx) + if err != nil { + t.Fatal(err) + } + if got != encrypt.KeysetID(id) { + t.Fatalf("keyset id %s, want %s", got, encrypt.KeysetID(id)) + } + sealed := roundTrip(t, c, vendor) + // Every backend seals the v1 leaf: the leaf names its keyset, and the + // router sends it to the backend that holds that keyset. The default + // keyset stays on the ZeroKMS stand-in. + if v := sealed[0].Email.Ciphertext[0]; v != 1 { + t.Errorf("a vendor leaf has format version %d, want 1", v) + } + zerokms := roundTrip(t, c, c.DefaultKeyset()) + if v := zerokms[0].Email.Ciphertext[0]; v != 1 { + t.Errorf("a ZeroKMS leaf has format version %d, want 1", v) + } + + // A keyset-bound cipher refuses the other backend's rows before any key + // is asked for. + if _, err := testusers.Decrypt(ctx, c.DefaultKeyset(), sealed); !errors.Is(err, encrypt.ErrForeignKeyset) { + t.Errorf("default keyset opening a vendor row: %v, want ErrForeignKeyset", err) + } + + // Equal plaintexts give equal terms under the vendor keyset too, and + // they differ from the ZeroKMS keyset's. + if !sealed[1].Age.Equality.Equal(sealed[2].Age.Equality) || sealed[1].Age.Equality.Equal(zerokms[1].Age.Equality) { + t.Error("vendor equality terms do not follow the plaintext and the keyset") + } + + // The same record under another static key does not open. + other := vendorClient(t, refusing{}, encrypt.StaticKey{KeyRef: "dev-key", Key: bytes.Repeat([]byte{0x43}, 32)}, record) + if _, err := testusers.Encrypt(ctx, other.Keyset(encrypt.KeysetName("local")), people); !errors.Is(err, encrypt.ErrAuthentication) { + t.Errorf("a wrong static key: %v, want ErrAuthentication", err) + } +} + +// refusing is a RoundTripper for a backend that must make no request. +type refusing struct{} + +func (refusing) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("this test makes no HTTP request") +} + +// TestKeyVendorNamesMatchTheGuest pins the Go table of vendor names to the +// Rust names (KeyVendor::name in stack-kms). For each backend, the guest +// must accept the name the client sends as key_vendor, and a keyset record +// with the byte the client uses. The guest refuses a name it does not know, +// and a record of another vendor, so a drift on either side fails here. +func TestKeyVendorNamesMatchTheGuest(t *testing.T) { + token := encrypt.StaticAccessToken("t") + vendors := []encrypt.KeyVendor{ + encrypt.AWSKMS{Region: "us-east-1", Credentials: &countingCredentials{}}, + encrypt.GoogleCloudKMS{Token: token}, + encrypt.AzureKeyVault{Token: token}, + encrypt.VaultTransit{Address: "https://vault.example.com", Token: token}, + encrypt.StaticKey{KeyRef: "k", Key: bytes.Repeat([]byte{1}, 32)}, + } + seen := map[byte]string{} + for _, v := range vendors { + id, name, err := encrypt.KeyVendorWire(v) + if err != nil { + t.Fatal(err) + } + if other, ok := seen[id]; ok { + t.Fatalf("%s and %s have the same byte %d", other, name, id) + } + seen[id] = name + t.Run(name, func(t *testing.T) { + record := keysetRecord(randomID(t), id, modeDerived, "pin", "k", []byte{1}) + _ = vendorClient(t, refusing{}, v, record) + }) + } + if len(seen) != encrypt.KeyVendorNameCount() { + t.Errorf("the test covers %d vendors, the table has %d", len(seen), encrypt.KeyVendorNameCount()) + } +} + +// fakeKMS answers the AWS KMS JSON API for the three operations the client +// uses. A "ciphertext blob" is the key and the SHA-256 of the encryption +// context's cipherstash:aad, so a decrypt under another context fails as +// AWS KMS fails it. It checks that every request is SigV4-signed with the +// host's credentials. +type fakeKMS struct { + mu sync.Mutex + calls map[string]int + refuse bool + failures []string +} + +func (f *fakeKMS) fail(format string, args ...any) { + f.failures = append(f.failures, fmt.Sprintf(format, args...)) +} + +func (f *fakeKMS) RoundTrip(req *http.Request) (*http.Response, error) { + f.mu.Lock() + defer f.mu.Unlock() + body, err := io.ReadAll(req.Body) + _ = req.Body.Close() + if err != nil { + return nil, err + } + op := strings.TrimPrefix(req.Header.Get("X-Amz-Target"), "TrentService.") + if f.calls == nil { + f.calls = map[string]int{} + } + f.calls[op]++ + if req.URL.String() != "https://kms.ap-southeast-2.amazonaws.com/" { + f.fail("URL %s", req.URL) + } + if auth := req.Header.Get("Authorization"); !strings.HasPrefix(auth, "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/") || !strings.Contains(auth, "/ap-southeast-2/kms/aws4_request") { + f.fail("authorization %q", auth) + } + if token := req.Header.Get("X-Amz-Security-Token"); token != "fixture-session-token" { + f.fail("session token %q", token) + } + if f.refuse { + return response(400, `{"__type":"AccessDeniedException","message":"not allowed"}`), nil + } + var in struct { + KeyID string `json:"KeyId"` + CiphertextBlob []byte `json:"CiphertextBlob"` + EncryptionContext map[string]string `json:"EncryptionContext"` + } + if err := json.Unmarshal(body, &in); err != nil { + f.fail("body: %v", err) + return response(400, `{"__type":"ValidationException","message":"bad body"}`), nil + } + if in.KeyID != awsKeyARN { + f.fail("key %q", in.KeyID) + } + bound := sha256.Sum256([]byte(in.EncryptionContext["cipherstash:aad"])) + switch op { + case "Decrypt": + if len(in.CiphertextBlob) != 64 || !bytes.Equal(in.CiphertextBlob[32:], bound[:]) { + return response(400, `{"__type":"InvalidCiphertextException","message":""}`), nil + } + return response(200, fmt.Sprintf(`{"KeyId":%q,"Plaintext":%q}`, awsKeyARN, base64.StdEncoding.EncodeToString(in.CiphertextBlob[:32]))), nil + } + f.fail("operation %q", op) + return response(400, `{"__type":"UnsupportedOperationException","message":""}`), nil +} + +func (f *fakeKMS) count(op string) int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls[op] +} + +func response(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": {"application/x-amz-json-1.1"}}, + Body: io.NopCloser(strings.NewReader(body)), + ContentLength: int64(len(body)), + } +} + +const awsKeyARN = "arn:aws:kms:ap-southeast-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab" + +// awsRecord is a keyset record whose key the fake KMS unwraps: the key and +// the digest of the encryption context the client sends for it. +func awsRecord(t *testing.T, mode byte, name string) string { + t.Helper() + id := randomID(t) + aad := base64.RawURLEncoding.EncodeToString(recordKeyAAD(id, recordAWS, mode)) + bound := sha256.Sum256([]byte(aad)) + key := make([]byte, 32) + _, _ = rand.Read(key) + return keysetRecord(id, recordAWS, mode, name, awsKeyARN, append(key, bound[:]...)) +} + +// countingCredentials is the host's credential source: fixed temporary +// credentials, counted. +type countingCredentials struct { + mu sync.Mutex + asked int + err error +} + +func (c *countingCredentials) AWSCredentials(context.Context) (encrypt.AWSCredentials, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.asked++ + if c.err != nil { + return encrypt.AWSCredentials{}, c.err + } + return encrypt.AWSCredentials{ + AccessKeyID: "AKIDEXAMPLE", + SecretAccessKey: "fixture-secret-access-key", + SessionToken: "fixture-session-token", + Expires: time.Now().Add(time.Hour), + }, nil +} + +func TestAWSKMSVendorThroughTheHostTransport(t *testing.T) { + ctx := context.Background() + for _, tc := range []struct { + name string + mode byte + // The vendor calls one Encrypt of len(people) rows makes, by + // operation. + sealCalls map[string]int + }{ + // One unwrap of the root key, cached for its TTL. + {"derived", modeDerived, map[string]int{"Decrypt": 1}}, + } { + t.Run(tc.name, func(t *testing.T) { + kms := &fakeKMS{} + creds := &countingCredentials{} + c := vendorClient(t, kms, encrypt.AWSKMS{Region: "ap-southeast-2", Credentials: creds}, awsRecord(t, tc.mode, "payments")) + cipher := c.Keyset(encrypt.KeysetName("payments")) + if _, err := testusers.Encrypt(ctx, cipher, people); err != nil { + t.Fatal(err) + } + for op, want := range tc.sealCalls { + if got := kms.count(op); got != want { + t.Errorf("%s calls: %d, want %d", op, got, want) + } + } + roundTrip(t, c, cipher) + if len(kms.failures) > 0 { + t.Fatalf("the fake KMS saw: %s", strings.Join(kms.failures, "; ")) + } + calls := 0 + for _, n := range kms.calls { + calls += n + } + // The guest asks for a credential before each vendor request, and + // the client answers it from the source at most once per request. + if creds.asked < 1 || creds.asked > calls { + t.Errorf("the credential source was asked %d times for %d requests", creds.asked, calls) + } + }) + } +} + +func TestAWSKMSVendorFailures(t *testing.T) { + ctx := context.Background() + + // AWS KMS refuses: ErrForbidden, with the vendor in the diagnostic. + kms := &fakeKMS{refuse: true} + c := vendorClient(t, kms, encrypt.AWSKMS{Region: "ap-southeast-2", Credentials: &countingCredentials{}}, awsRecord(t, modeDerived, "payments")) + _, err := testusers.Encrypt(ctx, c.Keyset(encrypt.KeysetName("payments")), people) + if !errors.Is(err, encrypt.ErrForbidden) { + t.Fatalf("refused: %v, want ErrForbidden", err) + } + d := encrypt.WantDiagnostic(t, err, "stack_kms::backend") + if d.Fields["vendor"] != "aws-kms" { + t.Errorf("vendor field %v", d.Fields["vendor"]) + } + + // The host's credential source fails: the guest reports a transport + // failure, and the source's own error is attached for errors.Is. No + // request is sent. + errSource := errors.New("fixture credential source is down") + kms = &fakeKMS{} + c = vendorClient(t, kms, encrypt.AWSKMS{Region: "ap-southeast-2", Credentials: &countingCredentials{err: errSource}}, awsRecord(t, modeDerived, "payments")) + _, err = testusers.Encrypt(ctx, c.Keyset(encrypt.KeysetName("payments")), people) + if !errors.Is(err, errSource) || !errors.Is(err, encrypt.ErrTransport) { + t.Fatalf("source failure: %v, want the source's error and ErrTransport", err) + } + if kms.count("Decrypt") != 0 { + t.Error("a request was sent without credentials") + } + // No secret in the error text. + if strings.Contains(err.Error(), "fixture-secret") { + t.Errorf("the error holds a secret: %v", err) + } +} + +func TestKeyVendorOptionsAreCheckedBeforeAnythingResolves(t *testing.T) { + ctx := context.Background() + aws := encrypt.AWSKMS{Region: "us-east-1", Credentials: encrypt.AWSCredentialsFromEnv()} + for _, tc := range []struct { + name string + opts []encrypt.ClientOption + }{ + {"records with no vendor", []encrypt.ClientOption{encrypt.WithKeysetRecords("stack-keyset:AQ")}}, + {"a vendor with no records", []encrypt.ClientOption{encrypt.WithKeyVendor(aws)}}, + {"no region", []encrypt.ClientOption{encrypt.WithKeyVendor(encrypt.AWSKMS{Credentials: aws.Credentials}), encrypt.WithKeysetRecords("r")}}, + {"no credentials", []encrypt.ClientOption{encrypt.WithKeyVendor(encrypt.AWSKMS{Region: "us-east-1"}), encrypt.WithKeysetRecords("r")}}, + {"no Vault address", []encrypt.ClientOption{encrypt.WithKeyVendor(encrypt.VaultTransit{Token: encrypt.StaticAccessToken("t")}), encrypt.WithKeysetRecords("r")}}, + {"no Google token", []encrypt.ClientOption{encrypt.WithKeyVendor(encrypt.GoogleCloudKMS{}), encrypt.WithKeysetRecords("r")}}, + {"no Azure token", []encrypt.ClientOption{encrypt.WithKeyVendor(encrypt.AzureKeyVault{}), encrypt.WithKeysetRecords("r")}}, + {"a short static key", []encrypt.ClientOption{encrypt.WithKeyVendor(encrypt.StaticKey{KeyRef: "k", Key: make([]byte, 16)}), encrypt.WithKeysetRecords("r")}}, + } { + t.Run(tc.name, func(t *testing.T) { + // No credentials are given: a check that ran after resolving + // would fail with ErrNoCredentials or a guest error instead. + _, err := encrypt.NewClient(ctx, tc.opts...) + if !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("%v, want ErrEncoding", err) + } + }) + } + + // A refused config still consumes a static key, and so does one a + // later WithKeyVendor replaced. + first, second := bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32) + _, err := encrypt.NewClient(ctx, + encrypt.WithKeyVendor(encrypt.StaticKey{KeyRef: "a", Key: first}), + encrypt.WithKeyVendor(encrypt.StaticKey{KeyRef: "b", Key: second})) + if !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("%v, want ErrEncoding", err) + } + if !bytes.Equal(first, make([]byte, 32)) || !bytes.Equal(second, make([]byte, 32)) { + t.Error("a static key was not wiped") + } +} + +func TestKeyVendorRecordsAreCheckedAtNewClient(t *testing.T) { + staticKey := bytes.Repeat([]byte{0x42}, 32) + for _, tc := range []struct { + name, code string + record string + }{ + {"another vendor's record", "stack_kms_vendor::foreign_vendor", awsRecord(t, modeDerived, "payments")}, + {"a record in an unknown key mode", "stack_kms_vendor::record_mode", keysetRecord(randomID(t), recordStatic, modeUnknown, "", "dev-key", []byte{1})}, + {"not a record", "stack_kms_vendor::record_prefix", "payments"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := encrypt.NewDeterministicVendorClient(context.Background(), testSeed, refusing{}, + encrypt.StaticKey{KeyRef: "dev-key", Key: append([]byte(nil), staticKey...)}, tc.record) + if errors.Is(err, encrypt.ErrDeterministicGuestNotBuilt) { + t.Skip(err) + } + if !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("%v, want ErrEncoding", err) + } + encrypt.WantCode(t, err, tc.code) + }) + } +} + +func TestVendorCredentialsDoNotPrintTheirSecrets(t *testing.T) { + c := encrypt.AWSCredentials{AccessKeyID: "AKIDEXAMPLE", SecretAccessKey: "fixture-secret", SessionToken: "fixture-session"} + tok := encrypt.AccessToken{Token: "fixture-token"} + for _, s := range []string{fmt.Sprint(c), fmt.Sprintf("%+v", c), fmt.Sprintf("%#v", c), fmt.Sprint(tok), fmt.Sprintf("%#v", tok)} { + if strings.Contains(s, "fixture") { + t.Errorf("printed a secret: %s", s) + } + } +} + +func TestStaticKeyDoesNotPrintItsKey(t *testing.T) { + // 0xAB prints as 171 in %v and as ab in %x. + k := encrypt.StaticKey{KeyRef: "dev-key", Key: bytes.Repeat([]byte{0xAB}, 32)} + var logged bytes.Buffer + slog.New(slog.NewTextHandler(&logged, nil)).Info("vendor", "key", k) + for _, s := range []string{fmt.Sprint(k), fmt.Sprintf("%+v", k), fmt.Sprintf("%#v", k), fmt.Sprintf("%x", k), logged.String()} { + if strings.Contains(s, "171") || strings.Contains(strings.ToLower(s), "abab") { + t.Errorf("printed the key: %s", s) + } + if !strings.Contains(s, "dev-key") && !strings.Contains(s, hex.EncodeToString([]byte("dev-key"))) { + t.Errorf("lost the key ref: %s", s) + } + } +} + +// noZeroKMSEnvironment clears every place AutoCredentials would look, so a +// test that passes proves NewClient did not look. +func noZeroKMSEnvironment(t *testing.T) { + t.Helper() + for _, name := range []string{"CS_CLIENT_ID", "CS_CLIENT_KEY", "CS_CLIENT_ACCESS_KEY", "CS_WORKSPACE_CRN", "CS_ZEROKMS_HOST", "CS_VITUR_HOST", "CS_CONFIG_PATH"} { + t.Setenv(name, "") + } + t.Setenv("HOME", t.TempDir()) +} + +// vendorOnlyClient is NewClient with a key vendor backend and no +// credentials, over the embedded guest, or a skip when it is absent. +func vendorOnlyClient(t *testing.T, opts ...encrypt.ClientOption) (*encrypt.Client, error) { + t.Helper() + if _, err := encrypt.EmbeddedGuest(); err != nil { + t.Skip(err) + } + c, err := encrypt.NewClient(context.Background(), opts...) + if err == nil { + t.Cleanup(func() { _ = c.Close() }) + } + return c, err +} + +func TestVendorOnlyClientWithAStaticKey(t *testing.T) { + noZeroKMSEnvironment(t) + ctx := context.Background() + staticKey := bytes.Repeat([]byte{0x42}, 32) + record := func(name string) (string, [16]byte) { + id := randomID(t) + root := make([]byte, 32) + _, _ = rand.Read(root) + return keysetRecord(id, recordStatic, modeDerived, name, "dev-key", staticWrap(t, staticKey, root, recordKeyAAD(id, recordStatic, modeDerived))), id + } + first, _ := record("first") + second, secondID := record("second") + vendor := func() encrypt.KeyVendor { + return encrypt.StaticKey{KeyRef: "dev-key", Key: append([]byte(nil), staticKey...)} + } + + // Two records and no default: there is no default keyset. + _, err := vendorOnlyClient(t, encrypt.WithKeyVendor(vendor()), encrypt.WithKeysetRecords(first, second)) + if !errors.Is(err, encrypt.ErrNotFound) { + t.Fatalf("two records and no default: %v, want ErrNotFound", err) + } + + c, err := vendorOnlyClient(t, encrypt.WithKeyVendor(vendor()), encrypt.WithKeysetRecords(first, second), + encrypt.WithDefaultKeyset(encrypt.KeysetName("second"))) + if err != nil { + t.Fatal(err) + } + def, err := c.DefaultKeyset().KeysetID(ctx) + if err != nil { + t.Fatal(err) + } + if def != encrypt.KeysetID(secondID) { + t.Fatalf("default keyset %s, want %s", def, encrypt.KeysetID(secondID)) + } + sealed := roundTrip(t, c, c.DefaultKeyset()) + if v := sealed[0].Email.Ciphertext[0]; v != 1 { + t.Errorf("leaf format version %d, want 1", v) + } + roundTrip(t, c, c.Keyset(encrypt.KeysetName("first"))) + + // By id works as well as by name. + byID, err := vendorOnlyClient(t, encrypt.WithKeyVendor(vendor()), encrypt.WithKeysetRecords(first, second), + encrypt.WithDefaultKeyset(encrypt.KeysetID(secondID))) + if err != nil { + t.Fatal(err) + } + if _, err := testusers.Decrypt(ctx, byID.DefaultKeyset(), sealed); err != nil { + t.Fatalf("the default keyset by id does not open its rows: %v", err) + } + + // Anything that needs ZeroKMS fails with ErrNoZeroKMS: a keyset no + // record names, and a row a ZeroKMS keyset sealed. + if _, err := c.Keyset(encrypt.KeysetName("tenant-a")).KeysetID(ctx); !errors.Is(err, encrypt.ErrNoZeroKMS) { + t.Errorf("a keyset with no record: %v, want ErrNoZeroKMS", err) + } + zerokms, err := testusers.Encrypt(ctx, deterministicClient(t).DefaultKeyset(), people) + if err != nil { + t.Fatal(err) + } + if _, err := testusers.Decrypt(ctx, c, zerokms); !errors.Is(err, encrypt.ErrNoZeroKMS) { + t.Errorf("a ZeroKMS row: %v, want ErrNoZeroKMS", err) + } +} + +func TestVendorOnlyClientWithAWSKMS(t *testing.T) { + noZeroKMSEnvironment(t) + kms := &fakeKMS{} + creds := &countingCredentials{} + c, err := vendorOnlyClient(t, + encrypt.WithKeyVendor(encrypt.AWSKMS{Region: "ap-southeast-2", Credentials: creds}), + encrypt.WithKeysetRecords(awsRecord(t, modeDerived, "payments")), + encrypt.WithTransport(kms), + // Keep no root key, so each use of it unwraps it again. + encrypt.WithRootKeyTTL(0)) + if err != nil { + t.Fatal(err) + } + // NewClient loads the default keyset's index key, which unwraps the + // root key once. The client keeps the index key. + if n := kms.count("Decrypt"); n != 1 { + t.Errorf("NewClient made %d vendor calls, want 1", n) + } + roundTrip(t, c, c.DefaultKeyset()) + // The Encrypt uses the root key once (the data keys; the terms use the + // kept index key), and each of the two Decrypts uses it once. + if n := kms.count("Decrypt"); n != 4 { + t.Errorf("Decrypt calls: %d, want 4", n) + } + if len(kms.failures) > 0 { + t.Fatalf("the fake KMS saw: %s", strings.Join(kms.failures, "; ")) + } + // One credential per operation: NewClient, the Encrypt and each + // Decrypt ask once. + if creds.asked != 4 { + t.Errorf("the credential source was asked %d times, want 4", creds.asked) + } +} + +func TestVendorOnlyOptionsAreChecked(t *testing.T) { + noZeroKMSEnvironment(t) + ctx := context.Background() + static := func() encrypt.KeyVendor { + return encrypt.StaticKey{KeyRef: "k", Key: make([]byte, 32)} + } + for _, tc := range []struct { + name string + opts []encrypt.ClientOption + }{ + {"a default keyset with ZeroKMS credentials", []encrypt.ClientOption{ + encrypt.WithKeyVendor(static()), encrypt.WithKeysetRecords("r"), + encrypt.WithCredentials(encrypt.AutoCredentials()), encrypt.WithDefaultKeyset(encrypt.KeysetName("a")), + }}, + {"a default keyset with no vendor", []encrypt.ClientOption{encrypt.WithDefaultKeyset(encrypt.KeysetName("a"))}}, + {"the default selector as the default", []encrypt.ClientOption{ + encrypt.WithKeyVendor(static()), encrypt.WithKeysetRecords("r"), encrypt.WithDefaultKeyset(nil), + }}, + {"a root key TTL with no vendor", []encrypt.ClientOption{encrypt.WithRootKeyTTL(time.Minute)}}, + {"a negative root key TTL", []encrypt.ClientOption{ + encrypt.WithKeyVendor(static()), encrypt.WithKeysetRecords("r"), encrypt.WithRootKeyTTL(-time.Second), + }}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := encrypt.NewClient(ctx, tc.opts...); !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("%v, want ErrEncoding", err) + } + }) + } +} diff --git a/languages/golang/encrypt/options.go b/languages/golang/encrypt/options.go index 7a6bd19c5b..42488e601f 100644 --- a/languages/golang/encrypt/options.go +++ b/languages/golang/encrypt/options.go @@ -1,6 +1,9 @@ package encrypt -import "net/http" +import ( + "net/http" + "time" +) // ClientOption configures [NewClient]. Each one sets one thing; a later // option setting the same thing wins. NewClient with none is a working @@ -22,6 +25,17 @@ type clientOptions struct { transport http.RoundTripper guest []byte requireLockedMemory bool + // keyVendor is WithKeyVendor's backend; supersededVendors every earlier + // one a later WithKeyVendor replaced, whose StaticKey NewClient still + // wipes. + keyVendor KeyVendor + supersededVendors []KeyVendor + keysetRecords []string + // defaultKeyset is WithDefaultKeyset's keyset; rootKeyTTL is + // WithRootKeyTTL's, nil when not set. + defaultKeyset KeysetSelector + defaultKeysetSet bool + rootKeyTTL *time.Duration } // WithCredentials supplies the client id, the client key and the auth @@ -59,6 +73,11 @@ func WithKeysetCacheSize(n int) ClientOption { // [NewCredentials] the token exchange runs in the store the caller opened, // not through this RoundTripper: pass auth.WithRoundTripper to that // store instead. Nil means http.DefaultTransport, the default. +// +// With a key vendor backend ([WithKeyVendor]) the RoundTripper also sends +// the vendor requests, and it sees the keys that they and their responses +// carry in plaintext. Give it only a RoundTripper you trust with key +// material. func WithTransport(rt http.RoundTripper) ClientOption { return func(o *clientOptions) { o.transport = rt } } diff --git a/languages/golang/encrypt/step.go b/languages/golang/encrypt/step.go index 34f93350d1..e3ce33c6d6 100644 --- a/languages/golang/encrypt/step.go +++ b/languages/golang/encrypt/step.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/cipherstash/vitaminc/bindings/go/vcffi" @@ -18,6 +19,11 @@ import ( // runs, never across a network round trip, and a caller that gives up // between two steps aborts the operation (se_abort) while the guest is not // running at all. +// +// A key vendor backend pauses the same way, for three things: its HTTP +// requests, the short-lived credential it signs them with (answered from +// the backend's credential source, which may itself wait on the network), +// and the backoff before a retry. The guest blocks on none of them. // stepFunc runs one step of an operation in the guest, under ctx. type stepFunc func(ctx context.Context, inst *instance) ([]byte, error) @@ -29,14 +35,35 @@ type ioStep struct { requests []ioRequest } -// ioRequest is one HTTP request the guest needs performed. The headers -// carry the bearer token and the body key-material contexts, so both are -// wiped once performed. +// ioRequest is one thing the guest needs done: an HTTP request performed +// (method and url set), a key vendor credential fetched (credential set), +// or a wait sat out (wait set). An HTTP request's headers carry the bearer +// token and its body key-material contexts, so both are wiped once +// performed. type ioRequest struct { method, url string headers, body []byte + // credential is the key vendor credential the guest asks for. + credential *credentialRequest + // wait is how long the guest asks to wait, when it asks. + wait *time.Duration } +// credentialRequest is a key vendor credential the guest asks for: the +// vendor's KeyVendor byte and the OAuth scope, empty when it has none. +type credentialRequest struct { + vendor uint32 + scope string +} + +// maxScopeBytes bounds the scope of a credential request. A real OAuth +// scope is well under it. +const maxScopeBytes = 1024 + +// maxWait bounds a wait the guest asks for. A vendor client's backoff is +// far under it; a longer one is cut to it rather than hold a caller. +const maxWait = 30 * time.Second + func (s *ioStep) wipe() { for _, r := range s.requests { wipe(r.headers) @@ -123,6 +150,23 @@ func decodeRequest(item any) (ioRequest, bool) { if !ok { return ioRequest{}, false } + if len(obj) == 1 { + switch obj[0].Key { + case "credential": + c, ok := decodeCredentialRequest(obj[0].Value) + return ioRequest{credential: c}, ok + case "wait_ms": + ms, ok := obj[0].Value.(uint64) + if !ok { + return ioRequest{}, false + } + wait := maxWait + if ms < uint64(maxWait/time.Millisecond) { + wait = time.Duration(ms) * time.Millisecond //nolint:gosec // bounded by maxWait above + } + return ioRequest{wait: &wait}, true + } + } var r ioRequest for _, f := range obj { switch f.Key { @@ -151,11 +195,61 @@ func decodeRequest(item any) (ioRequest, bool) { return r, true } +// decodeCredentialRequest reads `{"vendor": u32, "scope": string}`. +func decodeCredentialRequest(value any) (*credentialRequest, bool) { + obj, ok := value.(vcvalue.Object) + if !ok { + return nil, false + } + var c credentialRequest + var sawVendor, sawScope bool + for _, f := range obj { + switch f.Key { + case "vendor": + c.vendor, ok = f.Value.(uint32) + sawVendor = true + case "scope": + c.scope, ok = f.Value.(string) + sawScope = true + default: + ok = false + } + if !ok { + return nil, false + } + } + if !sawVendor || !sawScope || len(c.scope) > maxScopeBytes { + return nil, false + } + return &c, true +} + +// operation is what the host keeps for one operation across its steps: the +// key vendor credentials it has fetched for it, so that an operation that +// makes several vendor requests asks the source once per vendor and scope, +// and why the source last failed, which the guest sees only as a failed +// request. The credentials are wiped when the operation ends. +type operation struct { + credentials map[credentialRequest][]byte + credErr error +} + +// end wipes the credentials the operation fetched. +func (op *operation) end() { + for _, c := range op.credentials { + wipe(c) + } + op.credentials = nil +} + // performStep performs every request of a step, in order, and encodes the // responses for se_resume: `[{"status": i32, "headers": bytes, "body": -// bytes}, ...]`. The responses can carry wrapped keys: the caller wipes the -// encoding once the guest has it, and every other copy is wiped here. -func (t *transport) performStep(ctx context.Context, step *ioStep) ([]byte, error) { +// bytes}, ...]`. The responses can carry key material (a ZeroKMS response, +// or from a key vendor backend an unwrapped key in plaintext) or a +// credential: the caller wipes the encoding once the guest has it, and +// every other copy this side holds is wiped here. The copies in net/http, +// TLS and the RoundTripper are not. +func (t *transport) performStep(ctx context.Context, step *ioStep, op *operation) ([]byte, error) { defer step.wipe() responses := make([]any, 0, len(step.requests)) var bodies [][]byte @@ -165,7 +259,16 @@ func (t *transport) performStep(ctx context.Context, step *ioStep) ([]byte, erro } }() for _, r := range step.requests { - status, headers, body := t.perform(ctx, r.method, r.url, r.headers, r.body) + var status int32 + var headers, body []byte + switch { + case r.credential != nil: + status, body = t.credential(ctx, *r.credential, op) + case r.wait != nil: + status = t.sit(ctx, *r.wait) + default: + status, headers, body = t.perform(ctx, r.method, r.url, r.headers, r.body) + } if headers == nil { headers = []byte{} } @@ -181,3 +284,44 @@ func (t *transport) performStep(ctx context.Context, step *ioStep) ([]byte, erro } return vcffi.Marshal(responses) } + +// credential answers a credential request: status 0 and the credential, +// encoded for the guest, or transportFailed and the error text. The +// credential is fetched once per operation, vendor and scope, outside the +// client's lock, under the caller's context; the body returned is a copy +// the caller wipes. +func (t *transport) credential(ctx context.Context, req credentialRequest, op *operation) (int32, []byte) { + if c, ok := op.credentials[req]; ok { + return 0, append([]byte(nil), c...) + } + if t.vendor == nil { + op.credErr = errors.New("encrypt: the guest asked for a key vendor credential, but the client has no key vendor backend") + return transportFailed, []byte(op.credErr.Error()) + } + cred, err := t.vendor.vendorCredential(ctx, req.vendor, req.scope) + if err != nil { + op.credErr = err + // The guest reads the text; it names the failure, never a secret, + // since the source's errors carry none. + return transportFailed, []byte(err.Error()) + } + if op.credentials == nil { + op.credentials = make(map[credentialRequest][]byte) + } + op.credentials[req] = cred + return 0, append([]byte(nil), cred...) +} + +// sit waits d, or until ctx ends: a retry's backoff, sat out with the +// client free for other callers. Status 0 once it has passed; a caller +// that gives up meanwhile ends the operation in drive, between steps. +func (t *transport) sit(ctx context.Context, d time.Duration) int32 { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return 0 + case <-ctx.Done(): + return transportFailed + } +} diff --git a/languages/golang/encrypt/step_test.go b/languages/golang/encrypt/step_test.go index 6abd5bf1ed..ce828f94fd 100644 --- a/languages/golang/encrypt/step_test.go +++ b/languages/golang/encrypt/step_test.go @@ -337,3 +337,65 @@ func marshalStep(t *testing.T, v any) []byte { } return b } + +// A key vendor credential request and a wait decode as their own kinds; a +// malformed one is refused. A wait past maxWait is cut to it. +func TestCredentialAndWaitRequestsDecode(t *testing.T) { + cred := vcvalue.Object{{Key: "credential", Value: vcvalue.Object{ + {Key: "vendor", Value: uint32(3)}, + {Key: "scope", Value: "https://vault.azure.net/.default"}, + }}} + r, ok := decodeRequest(cred) + if !ok || r.credential == nil || *r.credential != (credentialRequest{vendor: 3, scope: "https://vault.azure.net/.default"}) { + t.Fatalf("credential request decoded as %+v, %v", r, ok) + } + for _, tc := range []struct { + ms uint64 + want time.Duration + }{{250, 250 * time.Millisecond}, {0, 0}, {1 << 62, maxWait}} { + r, ok := decodeRequest(vcvalue.Object{{Key: "wait_ms", Value: tc.ms}}) + if !ok || r.wait == nil || *r.wait != tc.want { + t.Errorf("wait of %dms decoded as %+v, %v, want %v", tc.ms, r, ok, tc.want) + } + } + for name, bad := range map[string]vcvalue.Object{ + "no scope": {{Key: "credential", Value: vcvalue.Object{{Key: "vendor", Value: uint32(1)}}}}, + "string vendor": {{Key: "credential", Value: vcvalue.Object{{Key: "vendor", Value: "aws"}, {Key: "scope", Value: ""}}}}, + "extra key": {{Key: "credential", Value: vcvalue.Object{{Key: "vendor", Value: uint32(1)}, {Key: "scope", Value: ""}, {Key: "x", Value: ""}}}}, + "long scope": {{Key: "credential", Value: vcvalue.Object{{Key: "vendor", Value: uint32(1)}, {Key: "scope", Value: string(make([]byte, maxScopeBytes+1))}}}}, + "string wait": {{Key: "wait_ms", Value: "250"}}, + } { + if _, ok := decodeRequest(bad); ok { + t.Errorf("%s: accepted", name) + } + } +} + +// A wait ends early when its caller gives up, so a backoff never holds a +// caller past its context. +func TestAWaitEndsWithItsContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var tr transport + start := time.Now() + if status := tr.sit(ctx, time.Hour); status != transportFailed { + t.Errorf("status %d, want transportFailed", status) + } + if time.Since(start) > time.Second { + t.Error("the wait outlived its context") + } + if status := tr.sit(context.Background(), time.Millisecond); status != 0 { + t.Errorf("status %d, want 0", status) + } +} + +// A credential request on a client with no key vendor backend fails, and +// the operation keeps why. +func TestACredentialRequestWithNoKeyVendorFails(t *testing.T) { + var tr transport + var op operation + status, body := tr.credential(context.Background(), credentialRequest{vendor: 1}, &op) + if status != transportFailed || op.credErr == nil || len(body) == 0 { + t.Errorf("status %d, body %q, credErr %v", status, body, op.credErr) + } +} diff --git a/languages/golang/encrypt/transport.go b/languages/golang/encrypt/transport.go index d463a6b229..a43fa0034f 100644 --- a/languages/golang/encrypt/transport.go +++ b/languages/golang/encrypt/transport.go @@ -33,8 +33,10 @@ type tokenSource interface { Token(ctx context.Context) (string, error) } -// transport performs the guest's ZeroKMS requests over a RoundTripper and -// implements its token_get import over a tokenSource. One per Client. +// transport performs the guest's ZeroKMS and key vendor requests over a +// RoundTripper, answers its key vendor credential requests from that +// backend's credential source, and implements its token_get import over a +// tokenSource. One per Client. type transport struct { rt http.RoundTripper token tokenSource @@ -51,6 +53,9 @@ type transport struct { held string fetchErr error tokenErr error + // vendor is the client's key vendor backend, nil when it has none. Its + // credential source answers the guest's credential requests (step.go). + vendor *vendorSpec } // transportFailed is the status of a request that could not be performed @@ -203,6 +208,12 @@ func (b *requestBody) Close() error { // tokenGet is token_get: hand the guest the token fetched for this step. // It never waits: the fetch happened before the step (fetchToken). func (t *transport) tokenGet(ctx context.Context, m api.Module, tokenPtrOut, tokenLenOut uint32) int32 { + if t.token == nil { + // A client with a key vendor backend and no ZeroKMS has no token + // source; its guest builds no ZeroKMS client, so it never asks. + t.tokenErr = ErrNoZeroKMS + return hostFailed + } token := t.held if token == "" { t.tokenErr = t.fetchErr diff --git a/languages/golang/internal/guest/errors.go b/languages/golang/internal/guest/errors.go index 9020d2a2ad..5a53318114 100644 --- a/languages/golang/internal/guest/errors.go +++ b/languages/golang/internal/guest/errors.go @@ -91,6 +91,9 @@ var ( // A guest package's call loop takes the step, performs it and resumes; // it never reaches a caller. ErrNeedsIO = errors.New("cipherstash: the guest is waiting on the host for I/O") + // ErrNoZeroKMS is a ZeroKMS keyset asked of a client that has a key + // vendor backend and no ZeroKMS, refused before any request. + ErrNoZeroKMS = errors.New("cipherstash: this client has no ZeroKMS, only a key vendor backend") // ErrMemoryLock is guest memory that could not be locked in RAM (or, // on Linux, excluded from core dumps). A constructor returns it when // asked for locked memory and refused, and so does any later call under diff --git a/languages/golang/internal/guest/status.go b/languages/golang/internal/guest/status.go index f93e6a1d98..e9f7287535 100644 --- a/languages/golang/internal/guest/status.go +++ b/languages/golang/internal/guest/status.go @@ -39,7 +39,8 @@ const ( StatusContextMismatch = 28 // Not a failure: the operation is paused, waiting on HTTP requests the // host performs for it. See ErrNeedsIO. - StatusNeedsIO = 29 + StatusNeedsIO = 29 + StatusNoZeroKMS = 30 ) // StatusError is the sentinel a guest status decodes to. A status this host @@ -105,6 +106,8 @@ func StatusError(status uint32) error { return ErrContextMismatch case StatusNeedsIO: return ErrNeedsIO + case StatusNoZeroKMS: + return ErrNoZeroKMS default: return unrecognizedStatus(status) } diff --git a/mise.toml b/mise.toml index f252d73cb1..b38f29fc6e 100644 --- a/mise.toml +++ b/mise.toml @@ -124,7 +124,8 @@ check() { check stack-auth --no-default-features check stack-kms --no-default-features check stack-encrypt --no-default-features -# The key vendor seam (ADR-0014): no transport of its own, so it must build for WASI as is. Its +# The vendor seam the guest will carry key vendor backends through +# (ADR-0014): no transport of its own, so it must build for WASI as is. Its # `http` feature is the native transport, gated to non-wasm32 targets, so it # must add nothing here either. check stack-kms-vendor @@ -139,7 +140,7 @@ check stack-kms-vault # transport, key files and metadata server are gated off on wasm32. Its RS256 # signer (the `rsa` crate) is pure Rust. check stack-kms-gcp -# Without `service-account`, the form a build with no RSA uses. +# Without `service-account`, the form the Go guest builds (CIP-5311): no RSA. check stack-kms-gcp --no-default-features # The Azure Key Vault key vendor backend, default features on: its native # transport, workload identity and managed identity are gated off on wasm32. @@ -152,6 +153,8 @@ check stack-profile # status table, transport import); its export and import modules exist only # here. check stack-guest-abi +# The guest's vendor transport over the step loop. +check stack-guest-abi --features vendor echo "all WASI crates compile with no JS-host or native-HTTP deps" """ @@ -224,8 +227,9 @@ echo "embedded into languages/golang/encrypt/wasm/stack_encrypt_guest.wasm" # under encrypt/testdata, which `go build` and the package's `//go:embed # wasm` both ignore, so no binary ever carries it; the tests read it from # disk and skip when it is absent. The import-surface gate denies what the real -# build's does; it requires no transport import, because a build with no -# ZeroKMS client has none. +# build's does; it requires no token_get, because a build with no ZeroKMS +# client has none, and the key vendor clients need no import: they reach the +# host through the step loop. [tasks."wasm:guest:build:deterministic"] description = "Build the deterministic-kms TEST build of the stack-encrypt WASI guest (wasm32-wasip1, release), for the Go fixture and round-trip tests" shell = "bash -c" @@ -243,7 +247,9 @@ python3 "$root/scripts/check-wasm-imports.py" "$module" \\ --deny-prefix wasi_snapshot_preview1:fd_prestat \\ --require wasi_snapshot_preview1:random_get # No transport imports to require: with no ZeroKMS client in the build the -# linker drops the host module altogether, which is the point of the build. +# linker drops the host module altogether. The key vendor clients stay +# (CIP-5311) and reach the host through the step loop, so the hermetic Go +# tests drive a vendor through a fake RoundTripper with no import at all. cp "$module" ../testdata/stack_encrypt_guest_deterministic.wasm echo "test build at languages/golang/encrypt/testdata/stack_encrypt_guest_deterministic.wasm" """ diff --git a/packages/stack-guest-abi/Cargo.toml b/packages/stack-guest-abi/Cargo.toml index d125c2d489..a66e36d5ad 100644 --- a/packages/stack-guest-abi/Cargo.toml +++ b/packages/stack-guest-abi/Cargo.toml @@ -13,7 +13,15 @@ license-file = "LICENSE" # own; also keeps release-plz from picking it up. publish = false +[features] +# `vendor_transport`: the guest's `VendorTransport` for key vendor backends +# (ADR-0014), over the same `transport_send` import. Off by default, so the +# credential guest does not carry the vendor seam. +vendor = ["dep:stack-kms-vendor"] + [dependencies] +# Only `vendor_transport`. No default features: the guest has no native HTTP. +stack-kms-vendor = { path = "../stack-kms-vendor", default-features = false, optional = true } zeroize = { workspace = true } # `last_error`: an error is any miette diagnostic plus its structured fields # (a serde_json map, as `ErrorPayload::payload` gives them), encoded as a diff --git a/packages/stack-guest-abi/src/lib.rs b/packages/stack-guest-abi/src/lib.rs index 37a246a52e..b3e08849dc 100644 --- a/packages/stack-guest-abi/src/lib.rs +++ b/packages/stack-guest-abi/src/lib.rs @@ -111,3 +111,5 @@ pub mod value; pub mod abi; #[cfg(target_arch = "wasm32")] pub mod transport; +#[cfg(feature = "vendor")] +pub mod vendor_transport; diff --git a/packages/stack-guest-abi/src/status.rs b/packages/stack-guest-abi/src/status.rs index 7e32fdc4d2..974314ba3d 100644 --- a/packages/stack-guest-abi/src/status.rs +++ b/packages/stack-guest-abi/src/status.rs @@ -128,10 +128,16 @@ pub const STATUS_CONTEXT_MISMATCH: u32 = 28; /// them, and resumes it with the responses. No error is recorded. pub const STATUS_NEEDS_IO: u32 = 29; +/// A client with a key vendor backend and no ZeroKMS was asked for a +/// ZeroKMS keyset: a keyset none of its keyset records names, or a value a +/// ZeroKMS keyset sealed. Refused before any request. A configuration +/// error: the fix is ZeroKMS credentials, or a keyset record. +pub const STATUS_NO_ZEROKMS: u32 = 30; + /// The last code in the table. A guest appending a code of its own starts /// at `LAST_STATUS + 1` and moves this constant with it, so two guests can /// never claim one number. -pub const LAST_STATUS: u32 = STATUS_NEEDS_IO; +pub const LAST_STATUS: u32 = STATUS_NO_ZEROKMS; #[cfg(test)] mod tests { @@ -171,6 +177,7 @@ mod tests { STATUS_AUTH_REFRESH_REQUIRED, STATUS_CONTEXT_MISMATCH, STATUS_NEEDS_IO, + STATUS_NO_ZEROKMS, ]; for (i, code) in codes.iter().enumerate() { assert_eq!(*code, i as u32 + 1, "code {i} is out of sequence"); diff --git a/packages/stack-guest-abi/src/step.rs b/packages/stack-guest-abi/src/step.rs index e99047dbc4..a3fdb2ef6c 100644 --- a/packages/stack-guest-abi/src/step.rs +++ b/packages/stack-guest-abi/src/step.rs @@ -15,6 +15,12 @@ //! cancellation never interrupts it: an aborted operation is a future //! dropped between two polls, and everything it held is wiped on drop. //! +//! A request is one of three things the host does for the guest +//! ([`Request`]): an HTTP request, a key vendor backend's short-lived +//! credential, or a wait (a retry's backoff). The guest blocks on none of +//! them, so the host can fetch a credential over the network, or sit out a +//! backoff, with the instance free for other calls. +//! //! # Handles //! //! - A handle names one paused operation. It is issued when an operation @@ -50,6 +56,7 @@ use std::future::Future; use std::pin::Pin; use std::rc::Rc; use std::task::{Context, Poll, Waker}; +use std::time::Duration; use vitaminc_protected::OpaqueDebug; use zeroize::Zeroizing; @@ -64,41 +71,105 @@ pub type Operation = Pin, u32>>>>; /// The most operations that may be paused at once. pub const MAX_OPEN: usize = 64; -/// One HTTP request an operation needs the host to perform. +/// One thing an operation needs the host to do before it can go on: an +/// HTTP request to perform, a key vendor backend's credential to fetch, or +/// a wait to sit out. The host answers each with a [`Response`]. /// -/// The headers (in the [`crate::headers`] line format, a bearer token -/// among them) and the body (which can carry key-material contexts) are -/// wiped on drop. -#[derive(OpaqueDebug)] +/// All three pause the operation the same way, so the guest never blocks +/// on the host: the host fetches a credential and waits out a backoff +/// between steps, as it performs a request, with the instance free for +/// other calls. #[non_exhaustive] -pub struct Request { - /// The HTTP method. - #[non_sensitive] - pub method: &'static str, - /// The URL. - #[non_sensitive] - pub url: String, - /// The request headers. - pub headers: Zeroizing>, - /// The request body. - pub body: Zeroizing>, +pub enum Request { + /// An HTTP request. Answered with its HTTP response. + Http(HttpRequest), + /// A short-lived credential of the key vendor backend `vendor` (its + /// `KeyVendor` byte), for the OAuth `scope` (empty when the vendor has + /// none). Answered with status `0` and the credential, as a frame, in + /// the body; or a negative status and the error text, when the host has + /// none. + Credential { + /// The `KeyVendor` byte of the backend. + vendor: u8, + /// The OAuth scope, or empty. + scope: String, + }, + /// A wait of `duration`: the backoff before a retry. Answered with + /// status `0` and an empty body once it has passed. + Wait(Duration), } impl Request { - /// A request. + /// An HTTP request. pub fn new( method: &'static str, url: String, headers: Zeroizing>, body: Zeroizing>, ) -> Self { - Self { + Self::Http(HttpRequest { method, url, headers, body, + }) + } + + /// A request for a key vendor backend's credential. + pub fn credential(vendor: u8, scope: impl Into) -> Self { + Self::Credential { + vendor, + scope: scope.into(), } } + + /// A request to wait `duration`. + pub fn wait(duration: Duration) -> Self { + Self::Wait(duration) + } + + /// The HTTP request, when this is one. + pub fn http(&self) -> Option<&HttpRequest> { + match self { + Self::Http(request) => Some(request), + _ => None, + } + } +} + +/// `Debug` never shows an HTTP request's headers or body. +impl std::fmt::Debug for Request { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Http(request) => request.fmt(f), + Self::Credential { vendor, scope } => f + .debug_struct("Credential") + .field("vendor", vendor) + .field("scope", scope) + .finish(), + Self::Wait(duration) => f.debug_tuple("Wait").field(duration).finish(), + } + } +} + +/// One HTTP request an operation needs the host to perform. +/// +/// The headers (in the [`crate::headers`] line format, a bearer token +/// among them) and the body (which can carry key-material contexts) are +/// wiped on drop. +#[derive(OpaqueDebug)] +#[non_exhaustive] +pub struct HttpRequest { + /// The HTTP method. + #[non_sensitive] + pub method: &'static str, + /// The URL. + #[non_sensitive] + pub url: String, + /// The request headers. + pub headers: Zeroizing>, + /// The request body. + pub body: Zeroizing>, } /// The host's answer to one [`Request`]. @@ -498,10 +569,10 @@ mod tests { let _reset = reset(); let (handle, requests) = needs_io(start(sequential(2))); assert_eq!(requests.len(), 1); - assert_eq!(requests[0].url, "https://kms/0"); + assert_eq!(requests[0].http().unwrap().url, "https://kms/0"); let (again, requests) = needs_io(resume(handle, vec![ok(b"a")])); assert_eq!(again, handle, "a paused operation keeps its handle"); - assert_eq!(requests[0].url, "https://kms/1"); + assert_eq!(requests[0].http().unwrap().url, "https://kms/1"); let done = resume(handle, vec![ok(b"b")]).unwrap(); assert!(matches!(done, Step::Done(out) if out == b"ab")); assert_eq!(open_count(), 0); @@ -549,7 +620,10 @@ mod tests { Ok(out) }); let (handle, requests) = needs_io(start(work)); - let urls: Vec<_> = requests.iter().map(|r| r.url.as_str()).collect(); + let urls: Vec<_> = requests + .iter() + .map(|r| r.http().unwrap().url.as_str()) + .collect(); assert_eq!(urls, ["https://kms/a", "https://kms/b"]); let done = resume(handle, vec![ok(b"1"), ok(b"2")]).unwrap(); assert!(matches!(done, Step::Done(out) if out == b"12")); diff --git a/packages/stack-guest-abi/src/vendor_transport.rs b/packages/stack-guest-abi/src/vendor_transport.rs new file mode 100644 index 0000000000..091b90a756 --- /dev/null +++ b/packages/stack-guest-abi/src/vendor_transport.rs @@ -0,0 +1,209 @@ +//! The guest's [`VendorTransport`]: a key vendor backend's HTTP requests, +//! and its backoff waits, handed to the host through the step loop +//! (ADR-0014, [`crate::step`]). +//! +//! The vendor client signs each request inside the guest and the host only +//! carries the bytes, as for ZeroKMS. The conversions between the step's +//! form (a status that is negative for a transport failure, headers in the +//! [`crate::headers`] line format) and [`VendorResponse`] are pure, and are +//! tested on the native target. [`StepVendorTransport`] itself pauses the +//! operation, whose future is not `Send`, and so exists on wasm32 only. +//! +//! Only the transport is here. Which vendor the guest builds, and where its +//! credentials come from, is the guest's own wiring. + +use stack_kms_vendor::{TransportError, VendorResponse}; +use zeroize::Zeroizing; + +/// Encode request headers in the step's line format. +/// +/// A header name or value with a line break cannot be carried: it would +/// split into two lines (or inject a header). It is refused. +pub fn encode_request_headers( + headers: &[(String, String)], +) -> Result>, TransportError> { + let has_break = |text: &str| text.contains(['\n', '\r']); + if let Some((name, _)) = headers + .iter() + .find(|(name, value)| has_break(name) || has_break(value) || name.contains(':')) + { + return Err(TransportError(format!( + "the header {name:?} cannot be sent: a name or value has a line break or a name has a colon" + ))); + } + let pairs: Vec<(&str, &str)> = headers + .iter() + .map(|(name, value)| (name.as_str(), value.as_str())) + .collect(); + // The headers can hold a credential (a session token): wipe on drop. + Ok(Zeroizing::new(crate::headers::encode_headers(&pairs))) +} + +/// Decode response headers from the step's line format. Malformed lines +/// are skipped, as [`crate::headers::header_value`] does. +pub fn decode_response_headers(buffer: &[u8]) -> Vec<(String, String)> { + buffer + .split(|&byte| byte == b'\n') + .filter_map(|line| { + let line = std::str::from_utf8(line).ok()?; + let (name, value) = line.split_once(':')?; + Some((name.trim().to_owned(), value.trim().to_owned())) + }) + .collect() +} + +/// Turn the host's response to a step request into a [`VendorResponse`]. +/// +/// A negative status is a transport failure, and the body is then the +/// host's error text. A status above 999 cannot be an HTTP status and is a +/// transport failure too. +pub fn response_from_host( + status: i32, + headers: &[u8], + body: Zeroizing>, +) -> Result { + if status < 0 { + return Err(TransportError(format!( + "the host could not send the request: {}", + String::from_utf8_lossy(&body) + ))); + } + let status = u16::try_from(status) + .ok() + .filter(|status| (100..=999).contains(status)) + .ok_or_else(|| TransportError(format!("the host returned the status {status}")))?; + Ok(VendorResponse { + status, + headers: decode_response_headers(headers), + body, + }) +} + +/// The method of `request` as the step loop carries it: one of the HTTP +/// methods a vendor client sends. Any other is refused, not sent. +pub fn step_method(method: &str) -> Result<&'static str, TransportError> { + const METHODS: [&str; 6] = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]; + METHODS + .into_iter() + .find(|known| *known == method) + .ok_or_else(|| TransportError(format!("the HTTP method {method:?} cannot be sent"))) +} + +#[cfg(target_arch = "wasm32")] +pub use step_transport::StepVendorTransport; + +#[cfg(target_arch = "wasm32")] +mod step_transport { + use std::time::Duration; + + use stack_kms_vendor::{TransportError, VendorRequest, VendorResponse, VendorTransport}; + + use super::{encode_request_headers, response_from_host, step_method}; + use crate::step; + + /// A [`VendorTransport`] whose HTTP client is the host's, through the + /// step loop: `send` pauses the operation with the request, and `sleep` + /// pauses it with a wait, so the guest blocks on neither and the host + /// sits out a backoff with the instance free for other calls. + #[derive(Clone, Copy, Debug, Default)] + pub struct StepVendorTransport; + + impl VendorTransport for StepVendorTransport { + async fn send(&self, request: VendorRequest) -> Result { + let method = step_method(&request.method)?; + let headers = encode_request_headers(&request.headers)?; + let VendorRequest { url, body, .. } = request; + let response = step::request(step::Request::new(method, url, headers, body)) + .await + .map_err(|error| TransportError(error.to_string()))?; + response_from_host(response.status, &response.headers, response.body) + } + + async fn sleep(&self, duration: Duration) { + // The host answers a wait once it has passed. An operation that + // is not in a step (never the case for a vendor call) or a host + // that could not wait goes on at once: the retry still happens, + // only sooner. + let _ = step::request(step::Request::wait(duration)).await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_headers_encode_in_the_line_format() { + let headers = vec![ + ("host".to_owned(), "kms.us-east-1.amazonaws.com".to_owned()), + ("x-amz-target".to_owned(), "TrentService.Decrypt".to_owned()), + ]; + assert_eq!( + encode_request_headers(&headers).unwrap().as_slice(), + b"host: kms.us-east-1.amazonaws.com\nx-amz-target: TrentService.Decrypt" + ); + } + + #[test] + fn a_header_with_a_line_break_or_a_colon_in_its_name_is_refused() { + for (name, value) in [ + ("x-a", "1\nx-b: 2"), + ("x-a", "1\r"), + ("x-a\n", "1"), + ("x:a", "1"), + ] { + let headers = vec![(name.to_owned(), value.to_owned())]; + assert!(encode_request_headers(&headers).is_err(), "{name:?}"); + } + let headers = vec![("x-a".to_owned(), "a:b".to_owned())]; + assert!(encode_request_headers(&headers).is_ok()); + } + + #[test] + fn response_headers_decode_and_skip_malformed_lines() { + assert_eq!( + decode_response_headers(b"Content-Type: application/json\nbad line\nx-a: b:c "), + vec![ + ("Content-Type".to_owned(), "application/json".to_owned()), + ("x-a".to_owned(), "b:c".to_owned()), + ] + ); + assert!(decode_response_headers(b"").is_empty()); + assert!(decode_response_headers(&[0xff, b':', b'a']).is_empty()); + } + + #[test] + fn a_host_status_becomes_a_response_or_a_transport_error() { + let response = response_from_host( + 400, + b"x-amzn-errortype: ThrottlingException", + Zeroizing::new(b"{}".to_vec()), + ) + .unwrap(); + assert_eq!(response.status, 400); + assert_eq!( + response.header_value("X-Amzn-ErrorType"), + Some("ThrottlingException") + ); + assert_eq!(response.body.as_slice(), b"{}"); + + let error = + response_from_host(-1, b"", Zeroizing::new(b"dial tcp: refused".to_vec())).unwrap_err(); + assert!(error.0.contains("dial tcp: refused")); + assert!(response_from_host(99, b"", Zeroizing::new(vec![])).is_err()); + assert!(response_from_host(1000, b"", Zeroizing::new(vec![])).is_err()); + assert!(response_from_host(100, b"", Zeroizing::new(vec![])).is_ok()); + assert!(response_from_host(999, b"", Zeroizing::new(vec![])).is_ok()); + } + + #[test] + fn only_the_http_methods_a_vendor_client_sends_are_carried() { + for method in ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"] { + assert_eq!(step_method(method).unwrap(), method); + } + for method in ["post", "CONNECT", "", "POST\n"] { + assert!(step_method(method).is_err(), "{method:?}"); + } + } +}