From 7f5bbf8a75f602dcc56dc1d0022d667135c21069 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Fri, 6 Nov 2026 12:01:26 +1100 Subject: [PATCH 1/5] feat(stack-kms-gcp): put the service account login behind a feature (CIP-5311) The feature is on by default. A build without it has no RSA code. --- packages/stack-kms-gcp/Cargo.toml | 22 +++++++++++++++---- packages/stack-kms-gcp/src/auth/mod.rs | 21 +++++++++++++----- .../stack-kms-gcp/src/auth/service_account.rs | 5 +---- packages/stack-kms-gcp/src/lib.rs | 12 ++++++---- packages/stack-kms-gcp/src/time.rs | 5 +++-- 5 files changed, 46 insertions(+), 19 deletions(-) diff --git a/packages/stack-kms-gcp/Cargo.toml b/packages/stack-kms-gcp/Cargo.toml index cd172f85b..84bdadaf5 100644 --- a/packages/stack-kms-gcp/Cargo.toml +++ b/packages/stack-kms-gcp/Cargo.toml @@ -14,10 +14,16 @@ license-file = "LICENSE" # is on crates.io (published by hand; see stack-kms-vendor's Cargo.toml). [features] -default = ["http"] +default = ["http", "service-account"] # `GcpKms::from_env`: the native transport (`HttpVendorTransport`) and the # default credentials over it. Native only; on wasm32 it adds nothing. -http = ["stack-kms-vendor/http"] +http = ["stack-kms-vendor/http", "service-account"] +# `ServiceAccountKey` and `ServiceAccountLogin`: the RS256 JWT exchange. Off, +# the crate has no RSA code and no `rsa` dependency. The Go WASI guest turns +# it off: its host supplies the access token (ADR-0011), so the guest never +# signs with a service account key (RUSTSEC-2023-0071, see CIP-5311 in the +# decision log). +service-account = ["dep:rsa", "dep:rand_core"] [dependencies] stack-kms = { path = "../stack-kms", version = "0.1.0", default-features = false } @@ -30,9 +36,9 @@ futures = "0.3.25" # wasm32-wasip1 (ring and aws-lc-rs need a C toolchain there). See the # CIP-5308 section of docs/plans/2026-11-05-key-vendor-backends-decisions.md # for RUSTSEC-2023-0071. -rsa = { version = "0.9.10", default-features = false, features = ["std", "sha2", "pem", "u64_digit"] } +rsa = { version = "0.9.10", default-features = false, features = ["std", "sha2", "pem", "u64_digit"], optional = true } # The blinding RNG of the RSA signature (rsa 0.9 takes rand_core 0.6). -rand_core = { version = "0.6.4", features = ["getrandom"] } +rand_core = { version = "0.6.4", features = ["getrandom"], optional = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } @@ -53,5 +59,13 @@ vitaminc = { workspace = true, features = ["protected", "random"] } name = "live" required-features = ["http"] +[[test]] +name = "contract" +required-features = ["service-account"] + +[[test]] +name = "fixtures" +required-features = ["service-account"] + [package.metadata.docs.rs] all-features = true diff --git a/packages/stack-kms-gcp/src/auth/mod.rs b/packages/stack-kms-gcp/src/auth/mod.rs index ea220eea3..502995b9f 100644 --- a/packages/stack-kms-gcp/src/auth/mod.rs +++ b/packages/stack-kms-gcp/src/auth/mod.rs @@ -9,13 +9,14 @@ //! * [`ServiceAccountLogin`]: a service account key (the JSON file Google //! issues, [`ServiceAccountKey`]) signs a JWT with RS256 and exchanges it //! at the token endpoint (RFC 7523). Everywhere: the key can come from -//! bytes. +//! bytes. Feature `service-account` (on by default). //! * [`MetadataServerLogin`]: the token of the attached service account, //! from the metadata server of Compute Engine, GKE, Cloud Run and Cloud //! Functions. **Native only.** //! * [`CachedToken`]: keeps the token a login returns and gets a new one //! before it expires. -//! * [`DefaultCredentials`] (native): picks one from the environment. +//! * [`DefaultCredentials`] (native, feature `service-account`): picks one +//! from the environment. //! //! In the WASI guest the host supplies the token (ADR-0011): the guest //! implements [`TokenProvider`] over a host import. @@ -32,8 +33,10 @@ use std::sync::{Mutex as SyncMutex, PoisonError}; use std::time::Duration; use futures::lock::Mutex; +#[cfg(any(feature = "service-account", not(target_arch = "wasm32")))] use serde::Deserialize; use stack_kms::{BackendErrorKind, MaybeSend}; +#[cfg(any(feature = "service-account", not(target_arch = "wasm32")))] use stack_kms_vendor::{classify_status, VendorResponse}; use web_time::SystemTime; use zeroize::Zeroizing; @@ -43,15 +46,20 @@ use crate::time::{system_clock, Clock}; #[cfg(not(target_arch = "wasm32"))] mod metadata; +#[cfg(feature = "service-account")] mod service_account; #[cfg(not(target_arch = "wasm32"))] pub use metadata::{MetadataServerLogin, DEFAULT_METADATA_HOST, METADATA_TOKEN_PATH}; +#[cfg(feature = "service-account")] pub use service_account::{ - ServiceAccountKey, ServiceAccountLogin, ASSERTION_LIFETIME, CLOUD_KMS_SCOPE, DEFAULT_TOKEN_URI, + ServiceAccountKey, ServiceAccountLogin, ASSERTION_LIFETIME, DEFAULT_TOKEN_URI, JWT_BEARER_GRANT_TYPE, }; +/// The OAuth scope of Cloud KMS. +pub const CLOUD_KMS_SCOPE: &str = "https://www.googleapis.com/auth/cloudkms"; + /// An OAuth 2.0 access token, and how long it lasts from when it was /// issued. /// @@ -159,6 +167,7 @@ impl TokenProvider for AccessToken { } } +#[cfg(any(feature = "service-account", not(target_arch = "wasm32")))] #[derive(Deserialize)] struct TokenResponse { access_token: Option, @@ -166,6 +175,7 @@ struct TokenResponse { expires_in: u64, } +#[cfg(any(feature = "service-account", not(target_arch = "wasm32")))] #[derive(Deserialize)] struct TokenErrorBody { error: Option, @@ -174,6 +184,7 @@ struct TokenErrorBody { /// The access token of a token endpoint or metadata server response. /// `source` names it in errors. +#[cfg(any(feature = "service-account", not(target_arch = "wasm32")))] pub(crate) fn read_token_response( source: &str, response: &VendorResponse, @@ -352,7 +363,7 @@ impl TokenProvider for CachedToken

{ } } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "service-account", not(target_arch = "wasm32")))] mod default { use stack_kms_vendor::VendorTransport; @@ -420,7 +431,7 @@ mod default { } } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "service-account", not(target_arch = "wasm32")))] pub use default::DefaultCredentials; #[cfg(test)] diff --git a/packages/stack-kms-gcp/src/auth/service_account.rs b/packages/stack-kms-gcp/src/auth/service_account.rs index fc6de00ad..b7cacb612 100644 --- a/packages/stack-kms-gcp/src/auth/service_account.rs +++ b/packages/stack-kms-gcp/src/auth/service_account.rs @@ -14,12 +14,9 @@ use stack_kms_vendor::{VendorRequest, VendorTransport}; use web_time::SystemTime; use zeroize::Zeroizing; -use super::{read_token_response, AccessToken, AuthError, TokenProvider}; +use super::{read_token_response, AccessToken, AuthError, TokenProvider, CLOUD_KMS_SCOPE}; use crate::time::{system_clock, unix_seconds, Clock}; -/// The OAuth scope of Cloud KMS. -pub const CLOUD_KMS_SCOPE: &str = "https://www.googleapis.com/auth/cloudkms"; - /// Google's OAuth 2.0 token endpoint, the `token_uri` of every service /// account key Google issues. pub const DEFAULT_TOKEN_URI: &str = "https://oauth2.googleapis.com/token"; diff --git a/packages/stack-kms-gcp/src/lib.rs b/packages/stack-kms-gcp/src/lib.rs index 230a7b5b4..0360f01f4 100644 --- a/packages/stack-kms-gcp/src/lib.rs +++ b/packages/stack-kms-gcp/src/lib.rs @@ -77,12 +77,16 @@ mod config; pub mod crc32c; mod time; +#[cfg(all(feature = "service-account", not(target_arch = "wasm32")))] +pub use auth::DefaultCredentials; +#[cfg(not(target_arch = "wasm32"))] +pub use auth::MetadataServerLogin; pub use auth::{ - AccessToken, AuthError, CachedToken, ServiceAccountKey, ServiceAccountLogin, TokenProvider, - CLOUD_KMS_SCOPE, DEFAULT_REFRESH_BEFORE_EXPIRY, DEFAULT_TOKEN_URI, + AccessToken, AuthError, CachedToken, TokenProvider, CLOUD_KMS_SCOPE, + DEFAULT_REFRESH_BEFORE_EXPIRY, }; -#[cfg(not(target_arch = "wasm32"))] -pub use auth::{DefaultCredentials, MetadataServerLogin}; +#[cfg(feature = "service-account")] +pub use auth::{ServiceAccountKey, ServiceAccountLogin, DEFAULT_TOKEN_URI}; pub use client::{ compact_ciphertext, error_kind, split_wrapped_key, wrapped_key_version, GcpKms, GcpKmsError, CHECKSUM_MARKERS, INVALID_CIPHERTEXT_MARKERS, WRAPPED_KEY_FORMAT, diff --git a/packages/stack-kms-gcp/src/time.rs b/packages/stack-kms-gcp/src/time.rs index 7d1071b5e..d7085fea0 100644 --- a/packages/stack-kms-gcp/src/time.rs +++ b/packages/stack-kms-gcp/src/time.rs @@ -1,6 +1,6 @@ //! The time source of a client. -use web_time::{SystemTime, UNIX_EPOCH}; +use web_time::SystemTime; /// The time source of a token cache and of the service account JWT: /// `SystemTime::now` unless a test sets another. @@ -12,8 +12,9 @@ pub fn system_clock() -> Clock { } /// Seconds since the Unix epoch. A time before the epoch is 0. +#[cfg(feature = "service-account")] pub(crate) fn unix_seconds(time: SystemTime) -> u64 { - time.duration_since(UNIX_EPOCH) + time.duration_since(web_time::UNIX_EPOCH) .map(|elapsed| elapsed.as_secs()) .unwrap_or(0) } From cee092c35c0c9643cd9f0d47d3c005d1cee59362 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Fri, 6 Nov 2026 12:11:50 +1100 Subject: [PATCH 2/5] feat(go): key vendor backends through the encrypt guest (CIP-5311) The guest routes keysets between ZeroKMS and at most one key vendor backend (KeyRouter over GuestVendor: AWS KMS, Google Cloud KMS, Azure Key Vault, Vault Transit or a static key). The vendor clients sign in the guest and send through transport_send. The new import vendor_credential_get gives them a short-lived credential per request. The Go host adds WithKeyVendor and WithKeysetRecords, a credential source for each vendor kind with no vendor SDK dependency, the import, and the capability matrix in the package docs. --- .github/workflows/tests-golang.yml | 12 + languages/golang/encrypt/client.go | 36 +- languages/golang/encrypt/doc.go | 58 +- languages/golang/encrypt/errors.go | 13 +- languages/golang/encrypt/export_test.go | 43 + languages/golang/encrypt/guest/Cargo.lock | 106 +++ languages/golang/encrypt/guest/Cargo.toml | 14 +- languages/golang/encrypt/guest/src/abi.rs | 75 +- languages/golang/encrypt/guest/src/config.rs | 364 +++++++- languages/golang/encrypt/guest/src/host.rs | 94 +- languages/golang/encrypt/guest/src/lib.rs | 20 +- languages/golang/encrypt/guest/src/status.rs | 34 + languages/golang/encrypt/guest/src/vendor.rs | 889 +++++++++++++++++++ languages/golang/encrypt/guest_test.go | 2 +- languages/golang/encrypt/keyvendor.go | 475 ++++++++++ languages/golang/encrypt/keyvendor_test.go | 470 ++++++++++ languages/golang/encrypt/options.go | 6 + languages/golang/encrypt/transport.go | 53 +- mise.toml | 26 +- 19 files changed, 2749 insertions(+), 41 deletions(-) create mode 100644 languages/golang/encrypt/guest/src/vendor.rs create mode 100644 languages/golang/encrypt/keyvendor.go create mode 100644 languages/golang/encrypt/keyvendor_test.go diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index 9bfff334a..fdb0c8bdc 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -33,6 +33,12 @@ on: - packages/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/** @@ -58,6 +64,12 @@ on: - packages/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/languages/golang/encrypt/client.go b/languages/golang/encrypt/client.go index 4f89bd4ff..144b16a13 100644 --- a/languages/golang/encrypt/client.go +++ b/languages/golang/encrypt/client.go @@ -16,7 +16,8 @@ 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; calls are serialised internally, because a wasm instance // is single-threaded. Close it when done, as with any resource. // @@ -65,11 +66,23 @@ 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 @@ -97,6 +110,10 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) 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 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. @@ -152,6 +169,8 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) clientKey: resolved.ClientKey, zerokmsURL: zerokmsURL, keysetCacheSize: cfg.keysetCacheSize, + vendor: vendor, + keysetRecords: cfg.keysetRecords, }) if err != nil { return nil, err @@ -163,7 +182,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 @@ -283,6 +302,10 @@ 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 } // encodeConfig renders the se_cipher_init object. The result holds the @@ -309,6 +332,9 @@ func encodeConfig(cfg initConfig) ([]byte, error) { if cfg.keysetCacheSize > 0 { fields = append(fields, vcvalue.Field{Key: "keyset_cache_size", Value: strconv.Itoa(cfg.keysetCacheSize)}) } + if cfg.vendor != nil { + fields = append(fields, cfg.vendor.configFields(cfg.keysetRecords)...) + } return vcffi.Marshal(fields) } @@ -402,6 +428,7 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([ growth := c.inst.mem.GrowthRefusal() if c.transport != nil { c.transport.tokenErr = nil + c.transport.credentialErr = nil } out, err := f(c.inst) switch { @@ -438,6 +465,11 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([ err = fmt.Errorf("%w (token source: %w)", err, c.transport.tokenErr) } } + // The same for a key vendor backend's credential source: the guest + // reports only that vendor_credential_get failed. + if err != nil && c.transport != nil && c.transport.credentialErr != nil { + err = fmt.Errorf("%w (key vendor credential source: %w)", err, c.transport.credentialErr) + } if err != nil { return nil, err } diff --git a/languages/golang/encrypt/doc.go b/languages/golang/encrypt/doc.go index f161e8a96..7d1d4724f 100644 --- a/languages/golang/encrypt/doc.go +++ b/languages/golang/encrypt/doc.go @@ -115,10 +115,12 @@ // // # Transport and auth // -// The guest imports exactly two host functions: an HTTP send, served by any -// [net/http.RoundTripper], and a bearer-token fetch, served by the -// credentials' auth strategy. What crosses per ZeroKMS call is what would -// cross TLS anyway; derived key material never leaves the guest. Under +// The guest imports exactly three host functions: an HTTP send, served by +// any [net/http.RoundTripper], a bearer-token fetch, served by the +// credentials' auth strategy, and a key vendor credential fetch, served by +// the source given with [WithKeyVendor]. What crosses per ZeroKMS or vendor +// call is what would cross TLS anyway; derived and unwrapped key material +// never leaves the guest. 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 @@ -141,6 +143,54 @@ // 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 client can hold one key vendor backend beside ZeroKMS: [AWSKMS], +// [GoogleCloudKMS], [AzureKeyVault], [VaultTransit] or [StaticKey]. Give it +// with [WithKeyVendor], and its keyset records (the "stack-keyset:…" texts) +// with [WithKeysetRecords]. A keyset in a record goes to the vendor; every +// other keyset, and the default keyset, goes to ZeroKMS, so NewClient still +// needs ZeroKMS credentials. 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 only +// short-lived credentials, per request: temporary 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]. +// This package imports no vendor SDK. +// +// Each keyset record fixes one key mode. Wrapped data keys give each value +// its own vendor-wrapped data key. Derived data keys derive each data key +// from one root key per keyset (HKDF-SHA256). +// +// wrapped data keys derived data keys +// key lifetime index key: one settle; root key: cached in the guest +// data key: one value for 5 minutes; the rest derived +// vendor calls per 1 + one per sealed value 0, or 1 when the root key is +// Encrypt or Decrypt (Vault: 1 + 1 per 250) not cached +// the vendor can every call and every value the root key unwrap, at most +// refuse once per 5 minutes +// +// The associated data (keyset id, IV and field descriptor) is bound to every +// wrapped 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; Azure does not see the binding, so the guest checks it after +// the unwrap. [StaticKey] offers derived data keys only, and nothing outside +// the process can refuse or audit its use. +// +// 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 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 0a21d54af..e37b6ca2a 100644 --- a/languages/golang/encrypt/errors.go +++ b/languages/golang/encrypt/errors.go @@ -14,7 +14,8 @@ import "github.com/cipherstash/stack/languages/golang/internal/guest" // 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 @@ -30,15 +31,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. diff --git a/languages/golang/encrypt/export_test.go b/languages/golang/encrypt/export_test.go index 8fd779c95..05011efd8 100644 --- a/languages/golang/encrypt/export_test.go +++ b/languages/golang/encrypt/export_test.go @@ -10,6 +10,7 @@ import ( "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" ) // The test-only ways to give a client a token. The public API takes tokens @@ -133,6 +134,48 @@ func deterministicClient(ctx context.Context, wasm []byte, seed [32]byte) (*Clie return c, nil } +// 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 vendor_credential_get import. 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(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 7f542b6de..2aa20b101 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -446,6 +446,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -960,6 +961,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" @@ -2068,6 +2078,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", "vitaminc-aead-value", "vitaminc-protected", @@ -2081,6 +2096,7 @@ version = "0.0.0" dependencies = [ "miette", "serde_json", + "stack-kms-vendor", "thiserror 1.0.69", "vitaminc-aead-value", "vitaminc-protected", @@ -2115,6 +2131,96 @@ dependencies = [ "zerokms-protocol", ] +[[package]] +name = "stack-kms-aws" +version = "0.1.0" +dependencies = [ + "base64ct", + "futures", + "hmac", + "serde", + "serde_json", + "sha2 0.11.0", + "stack-kms", + "stack-kms-vendor", + "thiserror 1.0.69", + "url", + "vitaminc", + "web-time", + "zeroize", +] + +[[package]] +name = "stack-kms-azure" +version = "0.1.0" +dependencies = [ + "base64ct", + "futures", + "serde", + "serde_json", + "stack-kms", + "stack-kms-vendor", + "thiserror 1.0.69", + "url", + "vitaminc", + "web-time", + "zeroize", +] + +[[package]] +name = "stack-kms-gcp" +version = "0.1.0" +dependencies = [ + "base64ct", + "futures", + "serde", + "serde_json", + "stack-kms", + "stack-kms-vendor", + "thiserror 1.0.69", + "url", + "vitaminc", + "web-time", + "zeroize", +] + +[[package]] +name = "stack-kms-vault" +version = "0.1.0" +dependencies = [ + "base64ct", + "futures", + "serde", + "serde_json", + "stack-kms", + "stack-kms-vendor", + "thiserror 1.0.69", + "url", + "vitaminc", + "web-time", + "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", + "web-time", + "zeroize", +] + [[package]] name = "stack-profile" version = "0.43.0" diff --git a/languages/golang/encrypt/guest/Cargo.toml b/languages/golang/encrypt/guest/Cargo.toml index ef9814ecf..52fa5bf0b 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 `transport_send` import. # 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-0009, ADR-0011, 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 5e5d1c675..dd09d81b4 100644 --- a/languages/golang/encrypt/guest/src/abi.rs +++ b/languages/golang/encrypt/guest/src/abi.rs @@ -87,14 +87,26 @@ use stack_encrypt::dynamic::Scope; #[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 host transport +/// and the host's credential import; with none configured it holds no +/// keyset and every request goes to ZeroKMS. #[cfg(not(feature = "deterministic-kms"))] -type GuestKms = +type GuestZeroKms = stack_kms::StackKms; #[cfg(feature = "deterministic-kms")] -type GuestKms = crate::deterministic::DeterministicSource; +type GuestZeroKms = crate::deterministic::DeterministicSource; + +/// The instance's key vendor backend. +type GuestVendor = crate::vendor::GuestVendor< + stack_guest_abi::vendor_transport::HostVendorTransport, + crate::host::HostCredentialImport, +>; + +type GuestKms = stack_kms_vendor::KeyRouter; /// The instance's cipher: `stack-encrypt` over [`GuestKms`]. type GuestCipher = StackCipher; @@ -232,6 +244,7 @@ fn cipher_init(decoded: FfiValue) -> Result, u32> { ) })?; + let kms = stack_kms_vendor::KeyRouter::new(kms, build_vendor(config.vendor)?); let mut builder = StackCipher::builder().kms(kms); if let Some(size) = config.keyset_cache_size { builder = builder.keyset_cache_size(size); @@ -247,9 +260,29 @@ fn cipher_init(decoded: FfiValue) -> Result, u32> { fn cipher_init(decoded: FfiValue) -> Result, u32> { use vitaminc_protected::Controlled; - let not_a_seed = || malformed("the deterministic build's config is a 32-byte seed"); - let FfiValue::Bytes(seed) = decoded else { - return Err(not_a_seed()); + let not_a_seed = || { + malformed( + "the deterministic build's config is a 32-byte seed, or an object of a seed and the key vendor backend keys", + ) + }; + // The bare seed is the original form. The object form adds a key vendor + // backend beside the seeded ZeroKMS stand-in, for the hermetic Go tests + // of the vendor path: `{"seed": bytes, "key_vendor": .., ..}`. + let (seed, vendor) = match decoded { + FfiValue::Bytes(seed) => (seed, None), + FfiValue::Object(mut entries) => { + let vendor = crate::config::take_vendor_config(&mut entries) + .map_err(|e| malformed(e.describe()))?; + let mut seed = None; + for (key, value) in entries { + match (key.as_str(), value) { + ("seed", FfiValue::Bytes(bytes)) if seed.is_none() => seed = Some(bytes), + _ => return Err(not_a_seed()), + } + } + (seed.ok_or_else(not_a_seed)?, vendor) + } + _ => return Err(not_a_seed()), }; let seed: [u8; 32] = seed .risky_ref() @@ -259,10 +292,34 @@ fn cipher_init(decoded: FfiValue) -> Result, u32> { if SHUT_DOWN.with(Cell::get) || CIPHER.with(|c| c.borrow().is_some()) { return Err(init_out_of_order()); } - let builder = StackCipher::builder().kms(crate::deterministic::DeterministicSource::new(seed)); + let kms = stack_kms_vendor::KeyRouter::new( + crate::deterministic::DeterministicSource::new(seed), + build_vendor(vendor)?, + ); + let builder = StackCipher::builder().kms(kms); install(block_on(builder.init()).map_err(|e| crate::status::fail_error(&e))?) } +/// Build the configured key vendor backend over the host transport and the +/// host's credential import (no vendor call: 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: Option, +) -> Result, u32> { + use crate::vendor::BuildError; + crate::vendor::build( + config, + stack_guest_abi::vendor_transport::HostVendorTransport, + crate::host::HostCredentialImport, + ) + .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, or after `se_shutdown`. fn init_out_of_order() -> u32 { out_of_order("the cipher is already initialised, or the instance is shut down") diff --git a/languages/golang/encrypt/guest/src/config.rs b/languages/golang/encrypt/guest/src/config.rs index 800461444..35c1032ab 100644 --- a/languages/golang/encrypt/guest/src/config.rs +++ b/languages/golang/encrypt/guest/src/config.rs @@ -11,6 +11,28 @@ //! | `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-0009, 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) | +//! +//! A vendor key for another backend than `key_vendor`, or with no +//! `key_vendor`, is refused, as an unknown key is. The vendor's credentials +//! are never in the config: the guest asks the host for them per request +//! (`vendor_credential_get`, 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. @@ -25,11 +47,15 @@ use std::num::NonZeroUsize; -use stack_kms::{ClientKey, ZeroKmsEndpoint}; +use stack_kms::{ClientKey, KeyVendor, ZeroKmsEndpoint}; +use stack_kms_vendor::KeyBytes; use uuid::Uuid; use vitaminc_aead_value::FfiValue; +use vitaminc_protected::Controlled; use zeroize::Zeroizing; +use crate::vendor::{VendorConfig, VendorSettings}; + /// A parse failure, carrying which key was at fault. Maps to /// `STATUS_ENCODING` at the ABI, and [`describe`](Self::describe) is the /// message the host reads through `se_last_error`: the name of the key at @@ -51,6 +77,12 @@ pub enum ConfigError { Duplicate(&'static str), /// A key this version does not recognise. UnknownKey(String), + /// A key held a value of the wrong type (`keyset_records` is an array + /// of strings, `static_key` bytes). + WrongType(&'static str), + /// A key vendor backend key that does not apply to the configured + /// `key_vendor`, or that was given with no `key_vendor`. + NotForVendor(&'static str), } impl ConfigError { @@ -68,6 +100,10 @@ impl ConfigError { Self::Invalid(key) => format!("the config's {key} is not valid"), Self::Duplicate(key) => format!("the config gives {key} twice"), Self::UnknownKey(_) => "the config has a key this version does not know".to_owned(), + Self::WrongType(key) => format!("the config's {key} has the wrong type"), + Self::NotForVendor(key) => { + format!("the config's {key} does not apply to its key_vendor") + } } } } @@ -77,14 +113,163 @@ pub struct CipherConfig { pub client_key: ClientKey, pub endpoint: Option, pub keyset_cache_size: Option, + /// The key vendor backend, when one is configured. + pub vendor: Option, +} + +/// The key vendor backend keys of the config, in the order the table above +/// gives them. +const VENDOR_KEYS: [&str; 10] = [ + "key_vendor", + "keyset_records", + "aws_region", + "aws_endpoint", + "vault_address", + "vault_namespace", + "vault_mount", + "gcp_endpoint", + "static_key_ref", + "static_key", +]; + +/// Take the key vendor backend keys out of `entries` and parse them. The +/// other keys stay for the caller. `None` when no vendor key is present. +/// +/// Both builds of the guest call this: the real one beside the ZeroKMS keys, +/// the deterministic test build beside its seed. +pub fn take_vendor_config( + entries: &mut Vec<(String, FfiValue)>, +) -> Result, ConfigError> { + let mut slots: Vec<(&'static str, FfiValue)> = Vec::new(); + let mut rest = Vec::with_capacity(entries.len()); + for (key, value) in entries.drain(..) { + match VENDOR_KEYS.iter().find(|k| **k == key) { + Some(name) => { + if slots.iter().any(|(k, _)| k == name) { + return Err(ConfigError::Duplicate(name)); + } + slots.push((name, value)); + } + None => rest.push((key, value)), + } + } + *entries = rest; + if slots.is_empty() { + return Ok(None); + } + let mut slots = VendorSlots(slots); + + let vendor = slots + .string("key_vendor")? + .ok_or(ConfigError::Missing("key_vendor"))?; + let vendor = KeyVendor::ALL + .into_iter() + .find(|v| v.name() == vendor.as_str()) + .ok_or(ConfigError::Invalid("key_vendor"))?; + let records = slots.records()?; + let settings = match vendor { + KeyVendor::Aws => VendorSettings::Aws { + region: slots.required("aws_region")?, + endpoint: slots.string("aws_endpoint")?.map(|s| s.to_string()), + }, + KeyVendor::Vault => VendorSettings::Vault { + address: slots.required("vault_address")?, + namespace: slots.string("vault_namespace")?.map(|s| s.to_string()), + mount: slots.string("vault_mount")?.map(|s| s.to_string()), + }, + KeyVendor::Gcp => VendorSettings::Gcp { + endpoint: slots.string("gcp_endpoint")?.map(|s| s.to_string()), + }, + KeyVendor::Azure => VendorSettings::Azure, + KeyVendor::Static => VendorSettings::Static { + key_ref: slots.required("static_key_ref")?, + key: slots.static_key()?, + }, + // `KeyVendor` is `#[non_exhaustive]`: a vendor this guest has no + // client for is not one it can configure. + _ => return Err(ConfigError::Invalid("key_vendor")), + }; + // Whatever is left was given for another backend. + if let Some((key, _)) = slots.0.first() { + return Err(ConfigError::NotForVendor(key)); + } + Ok(Some(VendorConfig { settings, records })) +} + +/// The vendor keys of one config, taken one at a time. +struct VendorSlots(Vec<(&'static str, FfiValue)>); + +impl VendorSlots { + fn take(&mut self, key: &'static str) -> Option { + let at = self.0.iter().position(|(k, _)| *k == key)?; + Some(self.0.swap_remove(at).1) + } + + fn string(&mut self, key: &'static str) -> Result>, ConfigError> { + self.take(key) + .map(|value| { + let FfiValue::String(s) = value else { + return Err(ConfigError::NotAString(key)); + }; + std::str::from_utf8(s.risky_ref()) + .map(|text| Zeroizing::new(text.to_owned())) + .map_err(|_| ConfigError::NotAString(key)) + }) + .transpose() + } + + fn required(&mut self, key: &'static str) -> Result { + let text = self.string(key)?.ok_or(ConfigError::Missing(key))?; + if text.is_empty() { + return Err(ConfigError::Invalid(key)); + } + Ok(text.to_string()) + } + + fn records(&mut self) -> Result, ConfigError> { + const KEY: &str = "keyset_records"; + let FfiValue::Array(items) = self.take(KEY).ok_or(ConfigError::Missing(KEY))? else { + return Err(ConfigError::WrongType(KEY)); + }; + if items.is_empty() { + return Err(ConfigError::Invalid(KEY)); + } + items + .into_iter() + .map(|item| match item { + FfiValue::String(s) => std::str::from_utf8(s.risky_ref()) + .map(str::to_owned) + .map_err(|_| ConfigError::WrongType(KEY)), + _ => Err(ConfigError::WrongType(KEY)), + }) + .collect() + } + + fn static_key(&mut self) -> Result { + const KEY: &str = "static_key"; + let FfiValue::Bytes(bytes) = self.take(KEY).ok_or(ConfigError::Missing(KEY))? else { + return Err(ConfigError::WrongType(KEY)); + }; + let mut key: [u8; 32] = bytes + .risky_ref() + .as_slice() + .try_into() + .map_err(|_| ConfigError::Invalid(KEY))?; + // `bytes` is `Protected` and wipes on drop; `KeyBytes` wipes its own + // copy, and the stack copy is wiped here. + let protected = KeyBytes::new(key); + zeroize::Zeroize::zeroize(&mut key); + Ok(protected) + } } /// Parse a decoded config value. Consumes it so the client-key material has /// one owner; the `Protected` payloads are wiped when the strings drop here. pub fn parse_config(value: FfiValue) -> Result { - let FfiValue::Object(entries) = value else { + let FfiValue::Object(mut entries) = value else { return Err(ConfigError::NotAnObject); }; + let vendor = take_vendor_config(&mut entries)?; // Every slot is a `Zeroizing`, not just the key one: the // client-key slot *must* wipe on every exit path (any of the `?`s below @@ -148,6 +333,7 @@ pub fn parse_config(value: FfiValue) -> Result { client_key, endpoint, keyset_cache_size, + vendor, }) } @@ -339,4 +525,178 @@ mod tests { Err(ConfigError::Invalid("zerokms_url")) )); } + + fn vendor_config(entries: Vec<(&str, FfiValue)>) -> Result, ConfigError> { + let mut entries: Vec<(String, FfiValue)> = entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(); + take_vendor_config(&mut entries) + } + + fn records() -> FfiValue { + FfiValue::Array(vec![FfiValue::String("stack-keyset:AQ".into())]) + } + + fn s(text: &str) -> FfiValue { + FfiValue::String(text.into()) + } + + #[test] + fn no_vendor_key_is_no_vendor_and_leaves_the_other_keys() { + let mut entries = vec![("client_id".to_string(), s("x"))]; + assert!(take_vendor_config(&mut entries).unwrap().is_none()); + assert_eq!(entries.len(), 1); + } + + #[test] + fn parses_each_vendor() { + let aws = vendor_config(vec![ + ("key_vendor", s("aws-kms")), + ("keyset_records", records()), + ("aws_region", s("us-east-1")), + ("aws_endpoint", s("http://localhost:4566")), + ]) + .unwrap() + .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 = vendor_config(vec![ + ("key_vendor", s("vault-transit")), + ("keyset_records", records()), + ("vault_address", s("https://vault.example.com")), + ("vault_mount", s("kms")), + ]) + .unwrap() + .unwrap(); + assert!(matches!( + vault.settings, + VendorSettings::Vault { + namespace: None, + mount: Some(_), + .. + } + )); + + for (name, expected) in [ + ("gcp-kms", KeyVendor::Gcp), + ("azure-key-vault", KeyVendor::Azure), + ] { + let config = + vendor_config(vec![("key_vendor", s(name)), ("keyset_records", records())]) + .unwrap() + .unwrap(); + assert_eq!(config.settings.vendor(), expected); + } + + let static_key = vendor_config(vec![ + ("key_vendor", s("static")), + ("keyset_records", records()), + ("static_key_ref", s("local")), + ( + "static_key", + FfiValue::Bytes(vitaminc_protected::Protected::new(vec![7u8; 32])), + ), + ]) + .unwrap() + .unwrap(); + assert_eq!(static_key.settings.vendor(), KeyVendor::Static); + // The static key never prints. + assert!(!format!("{static_key:?}").contains('7')); + } + + #[test] + fn refuses_bad_vendor_configs() { + let cases: Vec<(Vec<(&str, FfiValue)>, ConfigError)> = vec![ + ( + vec![("keyset_records", records())], + ConfigError::Missing("key_vendor"), + ), + ( + vec![("key_vendor", s("zerokms")), ("keyset_records", records())], + ConfigError::Invalid("key_vendor"), + ), + ( + vec![("key_vendor", s("azure-key-vault"))], + ConfigError::Missing("keyset_records"), + ), + ( + vec![ + ("key_vendor", s("azure-key-vault")), + ("keyset_records", FfiValue::Array(vec![])), + ], + ConfigError::Invalid("keyset_records"), + ), + ( + vec![ + ("key_vendor", s("azure-key-vault")), + ("keyset_records", s("stack-keyset:AQ")), + ], + ConfigError::WrongType("keyset_records"), + ), + ( + vec![("key_vendor", s("aws-kms")), ("keyset_records", records())], + ConfigError::Missing("aws_region"), + ), + ( + vec![ + ("key_vendor", s("aws-kms")), + ("keyset_records", records()), + ("aws_region", s("us-east-1")), + ("vault_address", s("https://vault.example.com")), + ], + ConfigError::NotForVendor("vault_address"), + ), + ( + vec![ + ("key_vendor", s("static")), + ("keyset_records", records()), + ("static_key_ref", s("local")), + ("static_key", s("not bytes")), + ], + ConfigError::WrongType("static_key"), + ), + ( + vec![ + ("key_vendor", s("static")), + ("keyset_records", records()), + ("static_key_ref", s("local")), + ( + "static_key", + FfiValue::Bytes(vitaminc_protected::Protected::new(vec![7u8; 31])), + ), + ], + ConfigError::Invalid("static_key"), + ), + ( + vec![("key_vendor", s("gcp-kms")), ("key_vendor", s("gcp-kms"))], + ConfigError::Duplicate("key_vendor"), + ), + ]; + for (entries, expected) in cases { + let error = vendor_config(entries).unwrap_err(); + assert_eq!(error, expected); + assert!(!error.describe().is_empty()); + } + } + + #[test] + fn a_full_config_carries_its_vendor() { + let (id, hex) = client_key_hex(); + let mut entries = match obj(vec![("client_id", &id.to_string()), ("client_key", &hex)]) { + FfiValue::Object(entries) => entries, + _ => unreachable!(), + }; + entries.push(("key_vendor".into(), s("gcp-kms"))); + entries.push(("keyset_records".into(), records())); + let cfg = parse_config(FfiValue::Object(entries)).expect("config parses"); + assert_eq!( + cfg.vendor.map(|v| v.settings.vendor()), + Some(KeyVendor::Gcp) + ); + } } diff --git a/languages/golang/encrypt/guest/src/host.rs b/languages/golang/encrypt/guest/src/host.rs index 7c7564f5f..b3c1618df 100644 --- a/languages/golang/encrypt/guest/src/host.rs +++ b/languages/golang/encrypt/guest/src/host.rs @@ -1,7 +1,9 @@ //! The host imports and the types built over them: [`WasiHostConnection`] -//! (a [`ZeroKMSConnection`] whose transport is the host's HTTP client) and +//! (a [`ZeroKMSConnection`] whose transport is the host's HTTP client), //! [`HostTokenStrategy`] (an [`AuthStrategy`] that asks the host for the -//! bearer token). wasm32-only — everything here calls an imported function. +//! bearer token) and [`HostCredentialImport`] (the key vendor backend's +//! short-lived credentials, see [`crate::vendor`]). wasm32-only — everything +//! here calls an imported function. //! //! # Import contract (module `cipherstash_transport`) //! @@ -18,12 +20,35 @@ //! status `0` is success, anything else a host-side failure. This //! guest's own: the credential guest supplies tokens rather than asking //! for them. +//! - `vendor_credential_get(vendor, scope_ptr, scope_len, cred_out) -> +//! status` — hand over one short-lived credential of the configured key +//! vendor backend (ADR-0011). `vendor` is the `KeyVendor` byte (1 AWS, +//! 2 Google Cloud, 3 Azure, 4 Vault); `scope` (UTF-8, borrowed for the +//! call) is the OAuth scope for Google Cloud and Azure and empty for AWS +//! and Vault. `cred_out` is a `(ptr_out, len_out)` slot pair filled with +//! an `se_alloc`'d buffer holding one value in the transport codec (its +//! keys are in [`crate::vendor`]). Status `0` is success, anything else a +//! host-side failure. Same shape as `token_get`. The host fills it from +//! the vendor's own Go credential chain; only ready-to-use, short-lived +//! credentials cross (temporary AWS credentials, an access token, a Vault +//! token), never a long-lived signing key, so the guest does no RSA and +//! reads no service account key. The import is referenced only by the +//! vendor clients: a guest built with no vendor client would not import it. //! //! What crosses the boundary per ZeroKMS call is exactly what would cross //! TLS anyway: the URL, the bearer token, and the serialized protocol //! bytes. Key material derived from a response never crosses back — the //! derivation runs inside the guest ([`stack_kms`]'s client, unmodified). //! +//! The same holds for a key vendor backend. A vendor request crosses as +//! the signed HTTP request the vendor would receive over TLS: a wrapped key +//! out, and, in the response, a key the vendor unwrapped. That response is +//! read only inside the guest, and the host wipes its own copy once it is +//! in guest memory (as for a ZeroKMS response). Root keys, index keys and +//! data keys are derived or unwrapped in the guest and never cross back to +//! the host. What crosses *in* through `vendor_credential_get` is a +//! short-lived credential, which the guest uses to sign and then drops. +//! //! 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). @@ -35,16 +60,27 @@ use stack_auth::{AuthError, AuthStrategy, CustomError, SecretToken, ServiceToken use stack_guest_abi::buffers; use stack_guest_abi::headers::header_value; use stack_guest_abi::transport; +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}; +use vitaminc_aead_value::transport as codec; +use vitaminc_aead_value::FfiValue; #[link(wasm_import_module = "cipherstash_transport")] extern "C" { fn token_get(token_ptr_out: *mut u32, token_len_out: *mut u32) -> i32; + fn vendor_credential_get( + vendor: u32, + scope_ptr: *const u8, + scope_len: u32, + cred_ptr_out: *mut u32, + cred_len_out: *mut u32, + ) -> i32; } /// A [`ZeroKMSConnection`] whose transport is the `transport_send` host @@ -195,3 +231,57 @@ impl AuthStrategy for &HostTokenStrategy { Ok(ServiceToken::new(SecretToken::new(text))) } } + +/// The `vendor_credential_get` import as a [`CredentialImport`]: the key +/// vendor backend's short-lived credential, asked of the host before each +/// vendor request. +#[derive(Clone, Copy, Debug, Default)] +pub struct HostCredentialImport; + +impl CredentialImport for HostCredentialImport { + fn credential(&self, vendor: KeyVendor, scope: &str) -> Result { + let failed = |message: String| CredentialError { + kind: BackendErrorKind::Unavailable, + message, + }; + let mut ptr: u32 = 0; + let mut len: u32 = 0; + let scope_len = u32::try_from(scope.len()) + .map_err(|_| failed("the credential scope is too long".to_owned()))?; + // SAFETY: `scope` is borrowed for the call; the out-slots are stack + // locals the host writes once. + let status = unsafe { + vendor_credential_get( + u32::from(vendor.byte()), + scope.as_ptr(), + scope_len, + &mut ptr, + &mut len, + ) + }; + // Reclaim before judging the status, as `token_get` does: a host + // that placed the credential and then failed must not leave it + // registered and unwiped. + // + // SAFETY: the pointer comes from the host's `se_alloc` call; the + // registry validates it before any Vec is rebuilt. + let bytes = unsafe { buffers::take(ptr as *mut u8, len as usize) }.map(Zeroizing::new); + if status != 0 { + return Err(failed(format!( + "the host's {} credential source failed with status {status}", + vendor.name() + ))); + } + let bytes = bytes + .ok_or_else(|| failed("the host credential buffer failed validation".to_owned()))?; + // The decoded strings are `Protected` and wipe on drop; `bytes` + // wipes through its `Zeroizing` wrapper. + codec::decode_value(&mut codec::Reader::new(&bytes)).map_err(|_| CredentialError { + kind: BackendErrorKind::AccessDenied, + message: format!( + "the host's {} credential is not a value in the transport codec", + vendor.name() + ), + }) + } +} diff --git a/languages/golang/encrypt/guest/src/lib.rs b/languages/golang/encrypt/guest/src/lib.rs index dfe7fbbdb..a87c5908d 100644 --- a/languages/golang/encrypt/guest/src/lib.rs +++ b/languages/golang/encrypt/guest/src/lib.rs @@ -38,11 +38,18 @@ //! //! 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 three imports (HTTP transport, the ZeroKMS bearer token +//! and a key vendor backend's short-lived credential — 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 (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 per request. //! //! The exports are the record path (`se_encrypt_record`, `se_decrypt_record`), //! per-field term derivation (`se_term`), a target field's EQL query value @@ -66,7 +73,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`. @@ -92,6 +99,7 @@ pub mod options; pub mod response; pub mod status; pub mod targets; +pub mod vendor; // 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 d4ec1494c..4ce9d8a7d 100644 --- a/languages/golang/encrypt/guest/src/status.rs +++ b/languages/golang/encrypt/guest/src/status.rs @@ -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::() +} + +/// 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 a mode the vendor does not offer, 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 diff --git a/languages/golang/encrypt/guest/src/vendor.rs b/languages/golang/encrypt/guest/src/vendor.rs new file mode 100644 index 000000000..ffecede94 --- /dev/null +++ b/languages/golang/encrypt/guest/src/vendor.rs @@ -0,0 +1,889 @@ +//! The key vendor backend of this guest (ADR-0009, ADR-0011, 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 it +//! is [`GuestVendor::None`] with no keyset records, and the router sends +//! every request to ZeroKMS, as before. +//! * The vendor clients send through the host's HTTP client +//! (`HostVendorTransport`, the `transport_send` import) and sign each +//! request here, in the guest. +//! * The credentials they sign with come from the host through the +//! `vendor_credential_get` import ([`CredentialImport`]): 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 import itself +//! is [`crate::host`]'s. +//! +//! # The credential the host returns +//! +//! One value in the transport codec, an object of string values plus an +//! optional `expires_at` (`i64`, Unix seconds): +//! +//! | 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::num::NonZeroUsize; +use std::time::{Duration, SystemTime}; + +use stack_kms::{BackendErrorKind, Error, KeyMode, KeyVendor}; +use stack_kms_vendor::{ + KeyBytes, KeyVendorBackend, KeysetRecord, RecordError, StaticKeyVendor, UnwrapItem, + VendorKeySource, VendorTransport, WrapItem, +}; +use vitaminc_aead_value::FfiValue; +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 VendorSettings { + /// The backend these settings build. + pub fn vendor(&self) -> KeyVendor { + match self { + Self::Aws { .. } => KeyVendor::Aws, + Self::Vault { .. } => KeyVendor::Vault, + Self::Gcp { .. } => KeyVendor::Gcp, + Self::Azure => KeyVendor::Azure, + Self::Static { .. } => KeyVendor::Static, + } + } +} + +impl std::fmt::Debug for VendorSettings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // The static key never prints. + f.debug_tuple("VendorSettings") + .field(&self.vendor().name()) + .finish() + } +} + +/// The key vendor backend part of the config: the backend and the keyset +/// record texts. +#[derive(Debug)] +pub struct VendorConfig { + pub settings: VendorSettings, + pub records: Vec, +} + +/// 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(), + } + } +} + +/// The `vendor_credential_get` host import, as a seam: one short-lived +/// credential for `vendor` and `scope`, as a decoded codec value. `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) -> Result; +} + +/// 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 `value`, the host's credential for `vendor`, at `now`. + fn read(vendor: KeyVendor, value: FfiValue, now: SystemTime) -> Result { + let not_valid = || { + CredentialError::denied(format!( + "the host's {} credential is not an object of string values", + vendor.name() + )) + }; + let FfiValue::Object(entries) = value else { + return Err(not_valid()); + }; + let mut strings = Vec::with_capacity(entries.len()); + let mut remaining = None; + for (key, value) in entries { + match (key.as_str(), value) { + ("expires_at", FfiValue::Int64(seconds)) => { + 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() + )) + })?); + } + (_, FfiValue::String(text)) => { + let text = std::str::from_utf8(text.risky_ref()).map_err(|_| not_valid())?; + // 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, text)); + } + _ => return Err(not_valid()), + } + } + 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, + value: FfiValue, + now: SystemTime, +) -> Result<(Zeroizing, Option), CredentialError> { + let mut fields = CredentialFields::read(vendor, value, 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 +/// import. 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 { + fn fetch(&self, vendor: KeyVendor, scope: &str) -> Result { + self.import.credential(vendor, scope) + } + + fn aws(&self) -> Result { + let vendor = KeyVendor::Aws; + let mut fields = CredentialFields::read(vendor, self.fetch(vendor, "")?, (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, + }) + } + + fn token( + &self, + vendor: KeyVendor, + scope: &str, + ) -> Result<(Zeroizing, Option), CredentialError> { + read_token(vendor, self.fetch(vendor, scope)?, (self.now)()) + } +} + +impl stack_kms_aws::CredentialsProvider for HostCredentials { + async fn credentials( + &self, + ) -> Result { + self.aws() + .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, "") + .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) + .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) + .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, + }) + } +} + +/// 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 { + /// No key vendor backend. It holds no keyset records, so the router + /// never calls it, and every method refuses. + None, + Aws(stack_kms_aws::AwsKms>), + Vault(stack_kms_vault::VaultTransit>), + Gcp(stack_kms_gcp::GcpKms>), + Azure(stack_kms_azure::AzureKeyVault>), + Static(StaticKeyVendor), +} + +impl std::fmt::Debug for GuestVendor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match self { + Self::None => "none", + Self::Aws(_) => KeyVendor::Aws.name(), + Self::Vault(_) => KeyVendor::Vault.name(), + Self::Gcp(_) => KeyVendor::Gcp.name(), + Self::Azure(_) => KeyVendor::Azure.name(), + Self::Static(_) => KeyVendor::Static.name(), + }; + f.debug_tuple("GuestVendor").field(&name).finish() + } +} + +/// Every method of [`GuestVendor::None`]: it holds no keyset, so a call is +/// a routing bug, refused rather than answered. +fn no_vendor() -> Error { + Error::Backend { + vendor: "none", + kind: BackendErrorKind::NotFound, + source: "no key vendor backend is configured".into(), + } +} + +/// Dispatch one `KeyVendorBackend` method to the configured client. +macro_rules! dispatch { + ($self:expr, $backend:ident => $call:expr, none => $none:expr) => { + match $self { + GuestVendor::None => $none, + GuestVendor::Aws($backend) => $call, + GuestVendor::Vault($backend) => $call, + GuestVendor::Gcp($backend) => $call, + GuestVendor::Azure($backend) => $call, + GuestVendor::Static($backend) => $call, + } + }; +} + +impl KeyVendorBackend for GuestVendor +where + T: VendorTransport + Send + Sync, + C: CredentialImport, +{ + /// The configured vendor. [`GuestVendor::None`] answers `Static`: it is + /// never asked, because it holds no keyset (the router asks a backend + /// only for a keyset one of its records names). + fn vendor(&self) -> KeyVendor { + dispatch!(self, b => b.vendor(), none => KeyVendor::Static) + } + + fn supports_mode(&self, mode: KeyMode) -> bool { + dispatch!(self, b => b.supports_mode(mode), none => false) + } + + async fn wrap(&self, key_ref: &str, key: &KeyBytes, aad: &[u8]) -> Result, Error> { + dispatch!(self, b => b.wrap(key_ref, key, aad).await, none => Err(no_vendor())) + } + + async fn unwrap(&self, key_ref: &str, wrapped: &[u8], aad: &[u8]) -> Result { + dispatch!(self, b => b.unwrap(key_ref, wrapped, aad).await, none => Err(no_vendor())) + } + + async fn generate_wrapped( + &self, + key_ref: &str, + aad: &[u8], + ) -> Result<(KeyBytes, Vec), Error> { + dispatch!(self, b => b.generate_wrapped(key_ref, aad).await, none => Err(no_vendor())) + } + + fn batch_limit(&self) -> Option { + dispatch!(self, b => b.batch_limit(), none => None) + } + + async fn wrap_batch( + &self, + key_ref: &str, + items: &[WrapItem<'_>], + ) -> Result>, Error> { + dispatch!(self, b => b.wrap_batch(key_ref, items).await, none => Err(no_vendor())) + } + + async fn unwrap_batch( + &self, + key_ref: &str, + items: &[UnwrapItem<'_>], + ) -> Result, Error> { + dispatch!(self, b => b.unwrap_batch(key_ref, items).await, none => Err(no_vendor())) + } + + async fn sleep(&self, duration: Duration) { + dispatch!(self, b => b.sleep(duration).await, none => ()) + } +} + +/// 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. With +/// no config, [`GuestVendor::None`] and no records. +pub fn build( + config: Option, + transport: T, + import: C, +) -> Result>, BuildError> +where + T: VendorTransport + Send + Sync, + C: CredentialImport, +{ + let Some(VendorConfig { settings, records }) = config else { + return Ok(VendorKeySource::new(GuestVendor::None, [])?); + }; + 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::, _>>()?; + Ok(VendorKeySource::new(backend, records)?) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + use stack_kms_aws::CredentialsProvider; + use stack_kms_vendor::{TransportError, VendorRequest, VendorResponse}; + use vitaminc_protected::Controlled; + + /// 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<(&str, FfiValue)>) -> FfiValue { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) + } + + fn string(text: &str) -> FfiValue { + FfiValue::String(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 { + fn credential(&self, vendor: KeyVendor, scope: &str) -> Result { + self.asked.lock().unwrap().push((vendor, scope.to_owned())); + (self.answer)() + } + } + + fn aws_value() -> FfiValue { + 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", FfiValue::Int64(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, FfiValue)>) -> FfiValue { + 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() -> FfiValue); + 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", FfiValue::Int64(expired())), + ]) + }), + ("negative expiry", || { + with(vec![ + ("secret_access_key", string("fixture-secret")), + ("expires_at", FfiValue::Int64(-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", FfiValue::Int64(1))]) + }), + ]; + for (label, value) in cases { + let provider = HostCredentials::new(FakeImport::new(move || Ok(value()))).with_now(now); + let error = 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(), + } + } + + #[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(Some(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") + ); + } + + #[test] + fn no_config_builds_no_vendor_and_no_keyset() { + let transport = Recording::default(); + let source = + build::<&Recording, FakeImport>(None, &transport, FakeImport::unused()).unwrap(); + assert!(matches!(source.backend(), GuestVendor::None)); + assert!(block(source.backend().unwrap("k", b"w", b"a")).is_err()); + assert!(!source.backend().supports_mode(KeyMode::Derived)); + } + + #[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(), + }; + let error = build(Some(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(), + }; + let error = build(Some(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:AQEBAQIDBAUGBwgJCgsMDQ4PEARhY21lAChhcm46YXdzOmttczp1cy1lYXN0LTE6MTExMTIyMjIzMzMzOmtleS9rAAMBAgM"; + let config = VendorConfig { + settings: VendorSettings::Static { + key_ref: "local".into(), + key: KeyBytes::new([7; 32]), + }, + records: vec![aws.to_owned()], + }; + let error = build(Some(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(Some(config), &Recording::default(), FakeImport::unused(),), + Err(BuildError::Record(_)) + )); + } +} diff --git a/languages/golang/encrypt/guest_test.go b/languages/golang/encrypt/guest_test.go index f6863f62a..7b0d8c5bf 100644 --- a/languages/golang/encrypt/guest_test.go +++ b/languages/golang/encrypt/guest_test.go @@ -140,7 +140,7 @@ func TestImportSurfaceIsWASIPlusTransport(t *testing.T) { t.Errorf("guest imports %s::%s, outside the allowed surface", module, name) } } - want := []string{"token_get", "transport_send"} + want := []string{"token_get", "transport_send", "vendor_credential_get"} sort.Strings(transportImports) if !reflect.DeepEqual(transportImports, want) { t.Fatalf("transport imports = %v, want %v", transportImports, want) diff --git a/languages/golang/encrypt/keyvendor.go b/languages/golang/encrypt/keyvendor.go new file mode 100644 index 000000000..827e0f19b --- /dev/null +++ b/languages/golang/encrypt/keyvendor.go @@ -0,0 +1,475 @@ +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. Keys stay in the guest: the +// host supplies only short-lived credentials, never key material. See the +// package documentation for what each backend and key mode 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 { + // name is the guest's key_vendor value. + name string + // id is the KeyVendor byte the guest names in vendor_credential_get. + id uint32 + // 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 +} + +// The KeyVendor bytes of the v2 leaf header and the keyset record. They +// cross the vendor_credential_get import. +const ( + vendorAWS = 1 + vendorGCP = 2 + vendorAzure = 3 + vendorVault = 4 + vendorStatic = 5 +) + +// AWSKMS is AWS KMS as the key vendor backend. The key in each keyset +// record is a KMS key ARN. Both key modes. +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{name: "aws-kms", id: 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/…). Both key modes. +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{name: "gcp-kms", id: 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. +// Both key modes. +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{name: "azure-key-vault", id: 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. +// Both key modes. +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{name: "vault-transit", id: 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. +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 +} + +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{ + name: "static", + id: 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 beside ZeroKMS. 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. +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 key; none holds a key in the clear. 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...) } +} + +// 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.name}, + {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]. +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 vendor != s.id { + return nil, fmt.Errorf("encrypt: the guest asked for a credential of key vendor %d, but the client's is %s", vendor, s.name) + } + 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.name) + } + 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.name) + } + 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 000000000..7f891082b --- /dev/null +++ b/languages/golang/encrypt/keyvendor_test.go @@ -0,0 +1,470 @@ +package encrypt_test + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "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 +// cross the real vendor_credential_get import. + +// The key vendor and key mode bytes of the keyset record (CIP-5306). +const ( + recordAWS = 1 + recordStatic = 5 + modeWrapped = 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 { + purpose := "root-key" + if mode == modeWrapped { + purpose = "index-key" + } + return frame([]byte("stack-kms-vendor/keyset-record-key/v1"), []byte(purpose), 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) + // A vendor keyset seals v2 leaves; the router keeps the default keyset + // on the ZeroKMS stand-in, which seals v1. + if v := sealed[0].Email.Ciphertext[0]; v != 2 { + t.Errorf("a vendor leaf has format version %d, want 2", 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") +} + +// 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 "GenerateDataKey": + key := make([]byte, 32) + _, _ = rand.Read(key) + blob := append(append([]byte(nil), key...), bound[:]...) + return response(200, fmt.Sprintf(`{"KeyId":%q,"Plaintext":%q,"CiphertextBlob":%q}`, awsKeyARN, base64.StdEncoding.EncodeToString(key), base64.StdEncoding.EncodeToString(blob))), nil + 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 + }{ + // Derived data keys: one unwrap of the root key, cached for its TTL. + {"derived", modeDerived, map[string]int{"Decrypt": 1}}, + // Wrapped data keys: the index key once per settle, and one + // GenerateDataKey per sealed field of every row (3 per row). + {"wrapped", modeWrapped, map[string]int{"Decrypt": 1, "GenerateDataKey": 3 * len(people)}}, + } { + 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 + } + if 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")}, + {"wrapped data keys under the static key", "stack_kms_vendor::mode_unsupported", keysetRecord(randomID(t), recordStatic, modeWrapped, "", "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) + } + } +} diff --git a/languages/golang/encrypt/options.go b/languages/golang/encrypt/options.go index 7a6bd19c5..96697292f 100644 --- a/languages/golang/encrypt/options.go +++ b/languages/golang/encrypt/options.go @@ -22,6 +22,12 @@ 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 } // WithCredentials supplies the client id, the client key and the auth diff --git a/languages/golang/encrypt/transport.go b/languages/golang/encrypt/transport.go index 85e349555..9e0ea91a8 100644 --- a/languages/golang/encrypt/transport.go +++ b/languages/golang/encrypt/transport.go @@ -16,7 +16,7 @@ import ( "github.com/tetratelabs/wazero/api" ) -// transportModule is the name of the guest's one import module. Its two +// transportModule is the name of the guest's one import module. Its three // functions are the whole host surface the guest can reach. const transportModule = "cipherstash_transport" @@ -31,8 +31,9 @@ type tokenSource interface { Token(ctx context.Context) (string, error) } -// transport implements the guest's two host imports over a RoundTripper -// and a tokenSource. One per Client; it is bound to the module at +// transport implements the guest's three host imports over a RoundTripper, +// a tokenSource and, when the client has a key vendor backend, that +// backend's credential source. One per Client; it is bound to the module at // instantiation and reaches the guest's allocator through the module the // call arrives on. type transport struct { @@ -47,6 +48,13 @@ type transport struct { // error it returns. Only touched under the client's lock, which every // guest call holds. tokenErr error + // vendor is the client's key vendor backend, nil when it has none. Its + // credential source answers vendor_credential_get. + vendor *vendorSpec + // credentialErr is why the key vendor credential source last failed + // during the call in flight, attached to the call's error as tokenErr + // is. Only touched under the client's lock. + credentialErr error } // transportFailed is the return value of transport_send when the request @@ -67,6 +75,7 @@ func (t *transport) instantiate(ctx context.Context, r wazero.Runtime) error { _, err := r.NewHostModuleBuilder(transportModule). NewFunctionBuilder().WithFunc(t.send).Export("transport_send"). NewFunctionBuilder().WithFunc(t.tokenGet).Export("token_get"). + NewFunctionBuilder().WithFunc(t.vendorCredentialGet).Export("vendor_credential_get"). Instantiate(ctx) if err != nil { return fmt.Errorf("encrypt: instantiating host transport: %w", err) @@ -235,6 +244,44 @@ func (t *transport) tokenGet(ctx context.Context, m api.Module, tokenPtrOut, tok return 0 } +// maxScopeBytes bounds the scope vendor_credential_get reads from the +// guest. A real OAuth scope is well under it. +const maxScopeBytes = 1024 + +// vendorCredentialGet is vendor_credential_get: hand the guest one +// short-lived credential of the client's key vendor backend, for scope. +// The guest asks before each vendor request and keeps nothing, so the +// source's own cache decides when to refresh. Only the credential crosses; +// the keys it unwraps stay in the guest. +func (t *transport) vendorCredentialGet(ctx context.Context, m api.Module, vendor, scopePtr, scopeLen, credPtrOut, credLenOut uint32) int32 { + if t.vendor == nil { + t.credentialErr = errors.New("encrypt: the guest asked for a key vendor credential, but the client has no key vendor backend") + return hostFailed + } + if scopeLen > maxScopeBytes { + t.credentialErr = fmt.Errorf("encrypt: the guest asked for a credential with a %d-byte scope", scopeLen) + return hostFailed + } + scope, ok := m.Memory().Read(scopePtr, scopeLen) + if !ok { + t.credentialErr = errors.New("encrypt: the credential scope is out of range") + return hostFailed + } + cred, err := t.vendor.vendorCredential(ctx, vendor, string(scope)) + if err != nil { + t.credentialErr = err + return hostFailed + } + // The credential's transport copy is wiped once it is in guest memory; + // the source's own strings are the source's. + defer wipe(cred) + if !place(ctx, m, credPtrOut, credLenOut, cred) { + t.credentialErr = errors.New("encrypt: the key vendor credential could not be handed to the guest") + return hostFailed + } + return 0 +} + // place allocates a guest buffer through the module's own se_alloc, writes // data into it, and stores its (ptr, len) into the out-slots. The guest // reclaims the buffer through its registry. Re-entering the guest through diff --git a/mise.toml b/mise.toml index 08dfddc77..2518fca36 100644 --- a/mise.toml +++ b/mise.toml @@ -136,6 +136,8 @@ 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 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. # It does no RSA itself (Azure does). @@ -205,7 +207,8 @@ python3 "$root/scripts/check-wasm-imports.py" "$module" \\ --deny-prefix wasi_snapshot_preview1:fd_prestat \\ --require wasi_snapshot_preview1:random_get \\ --require cipherstash_transport:transport_send \\ - --require cipherstash_transport:token_get + --require cipherstash_transport:token_get \\ + --require cipherstash_transport:vendor_credential_get # The Go module embeds the checked artefact (languages/golang/encrypt/wasm, # gitignored): copying it here is what makes `go test` in the binding run # against the guest just built rather than a stale one. @@ -220,8 +223,8 @@ 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, but it requires the two imports the key vendor clients use. [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" @@ -237,9 +240,13 @@ python3 "$root/scripts/check-wasm-imports.py" "$module" \\ --deny-prefix wasi_snapshot_preview1:path_ \\ --deny-prefix wasi_snapshot_preview1:sock_ \\ --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. + --require wasi_snapshot_preview1:random_get \\ + --require cipherstash_transport:transport_send \\ + --require cipherstash_transport:vendor_credential_get +# No token_get: with no ZeroKMS client in the build the linker drops it. The +# key vendor clients stay (CIP-5311), so the hermetic Go tests drive a vendor +# through a fake RoundTripper; they import transport_send and +# vendor_credential_get, and nothing else. cp "$module" ../testdata/stack_encrypt_guest_deterministic.wasm echo "test build at languages/golang/encrypt/testdata/stack_encrypt_guest_deterministic.wasm" """ @@ -270,7 +277,8 @@ python3 "$root/scripts/check-wasm-imports.py" "$module" \\ --deny-prefix wasi_snapshot_preview1:fd_prestat \\ --require wasi_snapshot_preview1:random_get \\ --require cipherstash_transport:transport_send \\ - --require cipherstash_transport:token_get + --require cipherstash_transport:token_get \\ + --require cipherstash_transport:vendor_credential_get mkdir -p ../eql/wasm cp "$module" ../eql/wasm/stack_encrypt_guest_eql.wasm echo "embedded into languages/golang/encrypt/eql/wasm/stack_encrypt_guest_eql.wasm" @@ -302,7 +310,9 @@ python3 "$root/scripts/check-wasm-imports.py" "$module" \\ --deny-prefix wasi_snapshot_preview1:path_ \\ --deny-prefix wasi_snapshot_preview1:sock_ \\ --deny-prefix wasi_snapshot_preview1:fd_prestat \\ - --require wasi_snapshot_preview1:random_get + --require wasi_snapshot_preview1:random_get \\ + --require cipherstash_transport:transport_send \\ + --require cipherstash_transport:vendor_credential_get cp "$module" ../testdata/stack_encrypt_guest_eql_deterministic.wasm echo "test build at languages/golang/encrypt/testdata/stack_encrypt_guest_eql_deterministic.wasm" """ From 10152e30086ef4368f61bb0e8e83c3712fe8e693 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Fri, 6 Nov 2026 12:12:30 +1100 Subject: [PATCH 3/5] docs(plans): record the CIP-5311 decisions and the guest size measurement --- ...026-11-05-key-vendor-backends-decisions.md | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/docs/plans/2026-11-05-key-vendor-backends-decisions.md b/docs/plans/2026-11-05-key-vendor-backends-decisions.md index 33b392f0a..039d6235e 100644 --- a/docs/plans/2026-11-05-key-vendor-backends-decisions.md +++ b/docs/plans/2026-11-05-key-vendor-backends-decisions.md @@ -1213,3 +1213,179 @@ the alternatives and the reason. - **Deferred (no ticket):** client certificate credentials (blocked on a constant-time RSA signer), Azure CLI and Arc credentials, re-recording the fixtures from real Azure once the live job has credentials. + +## CIP-5311: Go SDK: key vendor backends through the encrypt guest + +### Guest size (measured) + +- **Measurement** (release builds, `mise run wasm:guest:build` and + `wasm:guest:build:eql`, on this branch before and after): + + | build | before | after | growth | + |---|---|---|---| + | `stack_encrypt_guest.wasm` (no EQL types) | 1,543,167 B | 1,906,377 B | +363,210 B (+23.5%) | + | `stack_encrypt_guest_eql.wasm` | 1,598,896 B | 1,965,401 B | +366,505 B (+22.9%) | + | deterministic test build | n/a | 1,657,639 B | | + | deterministic test build, EQL | n/a | 1,726,501 B | | + + **Decision:** ship one guest with all five backends, as ADR-0011 says. + The growth is below the 30% limit the orchestrator set. **Alternative:** + one guest per vendor. **Reason:** it would multiply the embedded files, + the test builds and the release workflow. + +### The credential import + +- **Decision (binding, from the orchestrator):** the host supplies + ready-to-use short-lived credentials, never a long-lived signing key: AWS + temporary credentials, a Google OAuth access token, an Azure access token + for the scope the guest asks for, a Vault client token. The guest does no + RSA and reads no service account key. +- **Decision:** one import, `cipherstash_transport::vendor_credential_get( + vendor: u32, scope_ptr, scope_len, cred_ptr_out, cred_len_out) -> i32`, + the shape of `token_get`. `vendor` is the `KeyVendor` byte; `scope` is the + OAuth scope for Google Cloud and Azure and empty for AWS and Vault. The + credential is one value in the transport codec: an object of strings + (`access_key_id`, `secret_access_key`, optional `session_token` for AWS; + `token` for the others) and an optional `expires_at` (`i64`, Unix + seconds). **Alternative:** one import per credential kind; JSON. + **Reason:** one import keeps the surface small; the codec is the format + every other input uses, and its strings are wiped on drop. +- **Decision:** the guest asks before **each** vendor request and keeps no + credential between requests. `TokenProvider::invalidate` therefore has + nothing to drop in the guest, and the import has no "refresh" flag. The + host's credential chain (the AWS SDK cache, `oauth2.ReuseTokenSource`, + azidentity) decides when to refresh. **Alternative:** cache in the guest + with `CachedToken`/`CachedCredentials` and forward `invalidate` to the + host. **Reason:** credentials live in guest memory only for one request, + and the Go chains already cache. +- **Decision:** the guest refuses a credential whose `expires_at` has passed, + a credential with an unknown key, an empty or missing field, or a field + with a control character (`AccessDenied`, so `ErrForbidden`). A non-zero + import status is `Unavailable` (`ErrTransport`), the same status as a + failed `token_get`. The Go host attaches the source's own error to the + call's error, as it does for the token source, so `errors.Is` matches it. + **No new status codes:** the vendor failure kinds already map to the + ZeroKMS statuses (CIP-5304), and the `Diagnostic` code + `stack_kms::backend` with its `vendor` field tells them apart. +- **Decision:** `stack-kms-gcp` gets a default feature `service-account` + (the RS256 JWT login and the `rsa` and `rand_core` dependencies); `http` + implies it. The guest builds the crate with no default features, so the + `rsa` crate (RUSTSEC-2023-0071) is not in the guest's `Cargo.lock` at all. + `CLOUD_KMS_SCOPE` moved to `auth` so it stays without the feature. + `wasm:wasi-check` checks the crate without default features too. + **Alternative:** rely on LTO to drop the unused RSA code. **Reason:** the + lockfile, and so OSV, would still carry the advisory. + +### The guest + +- **Decision:** the cipher's key source is always + `KeyRouter` (in the deterministic build, + `KeyRouter`). `GuestVendor` is one + enum over the four vendor clients and `StaticKeyVendor`, plus `None`: with + no backend configured it holds no keyset record, so the router sends + every request to ZeroKMS and the zero-value client behaves as before. + **Alternative:** an enum over two cipher types, or an enum key source + that forwards every `DataKeySource`/`IndexKeySource` method. **Reason:** + `KeyRouter` already forwards all the provided methods correctly; a second + forwarder is where one would be forgotten. `GuestVendor::None` answers + `vendor()` with `Static`; it is never asked (it has no keyset). +- **Decision:** the vendor is configured in the `se_cipher_init` config + object: `key_vendor` (the `KeyVendor::name()`), `keyset_records` (an array + of record texts, required, not empty), and per vendor `aws_region`, + `aws_endpoint`, `vault_address`, `vault_namespace`, `vault_mount`, + `gcp_endpoint`, `static_key_ref`, `static_key` (32 bytes, a bytes value). + A key for another vendor than `key_vendor` is refused + (`ConfigError::NotForVendor`), as an unknown key is. No credential is in + the config. Azure needs no setting: the key identifier names the vault. + A refused record keeps its `RecordError` for `se_last_error` and is + `STATUS_ENCODING` (a `RecordError::Kms` keeps its key status). +- **Decision:** NewClient makes no vendor call: records are parsed and + checked at `se_cipher_init`, and a record key is unwrapped on the first + use of its keyset. **Reason:** the ZeroKMS default keyset is the only + load at init today; a vendor outage then fails the vendor keyset, not the + whole client. +- **Decision:** the deterministic test build keeps the vendor clients, so it + now imports `transport_send` and `vendor_credential_get` (not + `token_get`). Its config is the bare 32-byte seed, as before, or an object + `{"seed", key_vendor, keyset_records, ...}`. The mise import gates of all + four builds now require `vendor_credential_get`. +- **Decision:** the vendor client error types (`AwsKmsError`, + `CredentialsError`, `VaultError`, the three `AuthError`s, `GcpKmsError`, + `AzureKeyVaultError`, `TransportError`, `RecordError`) are causes whose + text the guest vouches for in `se_last_error`. **Reason:** each vendor + crate builds its messages from fixed text, the operation, the HTTP status + and the vendor's code and message, and never from a key or a secret. + +### The Go host + +- **Decision (dependency policy):** the module imports **no vendor SDK**. + The module already depends on `golang.org/x/oauth2` (for + `auth.OAuth2TokenSource`), and nothing else of a vendor. The credential + seam is two small interfaces with function adapters: + `AWSCredentialSource` (`AWSCredentialsFunc`) and `AccessTokenSource` + (`AccessTokenFunc`, `OAuth2AccessTokens(oauth2.TokenSource)`, + `StaticAccessToken`), plus `AWSCredentialsFromEnv`. The doc comments show + the three-line glue for aws-sdk-go-v2, azidentity and the Vault API + client. **Alternatives:** the vendor SDKs in the core module (four large + dependency trees for every user); a sub-package or sub-module per vendor + (`encrypt/vendor/aws`). **Reason:** sub-packages still add the SDKs to the + one `go.mod` every user downloads the requirements of, and sub-modules + need their own tags in `release-golang.yml`. The glue is short and is the + user's own choice of chain. Revisit if users ask for ready-made adapters; + then make them separate modules. +- **Decision:** the public surface is `WithKeyVendor(KeyVendor)`, + `WithKeysetRecords(...string)`, a sealed `KeyVendor` interface with five + struct types (`AWSKMS`, `GoogleCloudKMS`, `AzureKeyVault`, `VaultTransit`, + `StaticKey`), the credential types `AWSCredentials` and `AccessToken` + (both hide their secrets from `%v`, `%#v` and slog) and + `ErrNoKeyVendorCredentials`. Struct literals, not functional options, + because each backend has two to four settings. +- **Decision:** the options are checked host-side before anything is + resolved (Go principle 1, the earliest stage): records without a vendor, + a vendor without records, a missing region, address or credential + source, and a static key that is not 32 bytes are `ErrEncoding`. The + guest checks the records themselves at NewClient. +- **Decision:** `StaticKey.Key` is consumed like the client key: NewClient + wipes it whatever the outcome, a refused config and a replaced option + included. +- **Decision:** `ErrForbidden`, `ErrNotFound`, `ErrTransport` and + `ErrAuthentication` keep their names and now document their key vendor + meaning. **Alternative:** new `ErrKeyVendor*` sentinels. **Reason:** a + caller branches on the kind, not the backend; the `Diagnostic` names the + backend. + +### Tests + +- Guest (native): config parsing of every vendor and every refusal, the + credential readers (expiry, missing and unknown keys, control characters, + no secret in a message), the token providers' vendor and scope, an AWS + request signed with the host credentials, and record refusals. `mise run + wasm:guest:test`: 76 passed; clippy clean on all four feature sets, native + and wasm32. +- Go (hermetic, over the deterministic build): a static key round trip + (records built in the test by a second implementation of the record and + framing format), v2 leaves for the vendor keyset and v1 for ZeroKMS in one + client, `ErrForeignKeyset` across backends, a wrong static key; AWS KMS in + both modes through a fake `RoundTripper` that checks SigV4 with the host's + credentials and binds the encryption context, with exact vendor call + counts (derived: 1 `Decrypt`; wrapped: 1 `Decrypt` + 9 + `GenerateDataKey` for 3 rows of 3 sealed fields); an AWS refusal + (`ErrForbidden`, code `stack_kms::backend`, vendor `aws-kms`); a failed + credential source (`ErrTransport` plus the source's error, no request + sent); and the option and record checks. `mise run go:test` passes; + `golangci-lint` clean; `go generate ./...` changes nothing. +- **Not tested:** the Google Cloud, Azure and Vault clients through the Go + host (their requests are covered in their own crates; the guest test + checks their providers ask with the right vendor and scope). No live + vendor test from Go. + +### Deferred + +- **Deferred (no ticket):** a vendor-only Go client, with no ZeroKMS + credentials. Today the router's default keyset is ZeroKMS's, so NewClient + still needs ZeroKMS. It needs a guest key source of `VendorKeySource` + alone and a NewClient path with no `Credentials`. +- **Deferred (no ticket):** a root key TTL option in Go (the guest uses + `DEFAULT_ROOT_KEY_TTL`, 5 minutes). +- **Deferred to CIP-5313:** the stack-kms side of the capability matrix. + The Go package docs carry a short one. From cea4a914e0f8be395ae7ccedeefedbd12b6fe43e Mon Sep 17 00:00:00 2001 From: James Sadler Date: Fri, 6 Nov 2026 12:23:45 +1100 Subject: [PATCH 4/5] feat(go): a client with a key vendor backend and no ZeroKMS (CIP-5311) Add WithDefaultKeyset, WithRootKeyTTL and ErrNoZeroKMS. With WithKeyVendor and no WithCredentials, NewClient resolves no ZeroKMS credentials. The guest routes the default keyset to the vendor (KeyRouter::with_vendor_default) and refuses every ZeroKMS request with the new STATUS_NO_ZEROKMS. --- ...026-11-05-key-vendor-backends-decisions.md | 74 ++++++- languages/golang/encrypt/client.go | 60 ++++- languages/golang/encrypt/doc.go | 20 +- languages/golang/encrypt/errors.go | 5 + languages/golang/encrypt/guest/src/abi.rs | 83 ++++--- languages/golang/encrypt/guest/src/config.rs | 205 ++++++++++++++++-- languages/golang/encrypt/guest/src/lib.rs | 1 + languages/golang/encrypt/guest/src/status.rs | 7 +- languages/golang/encrypt/guest/src/vendor.rs | 53 ++++- languages/golang/encrypt/guest/src/zerokms.rs | 190 ++++++++++++++++ languages/golang/encrypt/keyvendor.go | 68 +++++- languages/golang/encrypt/keyvendor_test.go | 149 +++++++++++++ languages/golang/encrypt/options.go | 10 +- languages/golang/encrypt/transport.go | 6 + languages/golang/internal/guest/errors.go | 3 + languages/golang/internal/guest/status.go | 3 + packages/stack-guest-abi/src/status.rs | 9 +- packages/stack-kms-vendor/src/router.rs | 37 +++- packages/stack-kms-vendor/tests/router.rs | 30 +++ 19 files changed, 927 insertions(+), 86 deletions(-) create mode 100644 languages/golang/encrypt/guest/src/zerokms.rs diff --git a/docs/plans/2026-11-05-key-vendor-backends-decisions.md b/docs/plans/2026-11-05-key-vendor-backends-decisions.md index 039d6235e..e401767d4 100644 --- a/docs/plans/2026-11-05-key-vendor-backends-decisions.md +++ b/docs/plans/2026-11-05-key-vendor-backends-decisions.md @@ -1223,8 +1223,11 @@ the alternatives and the reason. | build | before | after | growth | |---|---|---|---| - | `stack_encrypt_guest.wasm` (no EQL types) | 1,543,167 B | 1,906,377 B | +363,210 B (+23.5%) | - | `stack_encrypt_guest_eql.wasm` | 1,598,896 B | 1,965,401 B | +366,505 B (+22.9%) | + | `stack_encrypt_guest.wasm` (no EQL types) | 1,543,167 B | 1,915,570 B | +372,403 B (+24.1%) | + | `stack_encrypt_guest_eql.wasm` | 1,598,896 B | 1,973,568 B | +374,672 B (+23.4%) | + + (The "after" column includes the vendor-only client below. Before it, + the two builds were 1,906,377 B and 1,965,401 B.) | deterministic test build | n/a | 1,657,639 B | | | deterministic test build, EQL | n/a | 1,726,501 B | | @@ -1279,7 +1282,8 @@ the alternatives and the reason. ### The guest - **Decision:** the cipher's key source is always - `KeyRouter` (in the deterministic build, + `KeyRouter, GuestVendor>` (see "A client with no + ZeroKMS" below) (in the deterministic build, `KeyRouter`). `GuestVendor` is one enum over the four vendor clients and `StaticKeyVendor`, plus `None`: with no backend configured it holds no keyset record, so the router sends @@ -1381,11 +1385,63 @@ the alternatives and the reason. ### Deferred -- **Deferred (no ticket):** a vendor-only Go client, with no ZeroKMS - credentials. Today the router's default keyset is ZeroKMS's, so NewClient - still needs ZeroKMS. It needs a guest key source of `VendorKeySource` - alone and a NewClient path with no `Credentials`. -- **Deferred (no ticket):** a root key TTL option in Go (the guest uses - `DEFAULT_ROOT_KEY_TTL`, 5 minutes). - **Deferred to CIP-5313:** the stack-kms side of the capability matrix. The Go package docs carry a short one. + +### A client with no ZeroKMS (orchestrator review) + +- **Decision (binding, from the orchestrator):** a key vendor backend is an + alternative to ZeroKMS, so a Go client may have a vendor and no ZeroKMS. + The deferral recorded first is withdrawn. +- **Decision:** the Go client has no ZeroKMS when `WithKeyVendor` is given + and `WithCredentials` is not. NewClient then neither needs nor resolves + ZeroKMS credentials (no environment, no profile, no `CS_ZEROKMS_HOST`). + To hold both, give `WithCredentials` too, for example + `WithCredentials(AutoCredentials())`. **Alternative:** try + `AutoCredentials` and fall back to vendor-only when it finds nothing. + **Reason:** a client whose backend changes with the developer's profile + or the environment is not predictable; a fallback would also hide a + broken ZeroKMS setup. +- **Decision:** the guest config has no `client_id`, `client_key` or + `zerokms_url` for such a client (any one of them makes the others + required, as before). The guest key source is + `KeyRouter::new(MaybeZeroKms::Absent, vendor).with_vendor_default()`. + `MaybeZeroKms` (guest, `zerokms.rs`) forwards every `DataKeySource` + and `IndexKeySource` method to `Present(z)`, and `Absent` refuses every + request with `Error::Backend { vendor: "zerokms", kind: NotFound, source: + NoZeroKms }`. **Alternative:** a second cipher type, or the vendor source + directly. **Reason:** one cipher type for every configuration, and the + router already forwards every provided method. +- **Decision (additive, stack-kms-vendor):** `KeyRouter::with_vendor_default` + sends "no keyset named" to the vendor and reports the vendor as + `key_backend()`. A router test covers it. +- **Decision:** a new status, `STATUS_NO_ZEROKMS = 29` (`LAST_STATUS` moves), + and a new Go sentinel `ErrNoZeroKMS`, for any request that reaches the + absent ZeroKMS: a keyset no record names, or a v1 leaf. The status layer + finds the `NoZeroKms` cause by type. **Alternative:** `ErrNotFound`. + **Reason:** it is a configuration error with one fix, and a caller must be + able to tell it from a missing keyset. +- **Decision:** the default keyset option is `WithDefaultKeyset(KeysetSelector)`, + taking a `KeysetName` or a `KeysetID`, as `Client.Keyset` does. It crosses + as the config key `default_keyset`, a selector object (`{"id": 16 bytes}` + or `{"name": …}`); the guest resolves a name from the records and calls + `VendorKeySource::with_default_keyset`. It is refused (`ErrEncoding`, and + `ConfigError::OnlyWithoutZeroKms` in the guest) on a client with ZeroKMS, + whose default the ZeroKMS administrator sets. With one record it is not + needed; with several and none, NewClient fails with `ErrNotFound` + (CIP-5306's rule). `nil`, `Client.DefaultKeyset`'s selector and the + "any" selector are refused. +- **Decision:** `WithRootKeyTTL(time.Duration)` (config `root_key_ttl_ms`, + decimal milliseconds) sets `VendorKeySource::with_root_key_ttl`. Zero + keeps none; negative, or with no vendor, is `ErrEncoding`. **Measured:** + with zero, one Encrypt that derives terms unwraps the root key twice (the + data keys, then the index key in the settle), and each Decrypt once. +- **Tests:** guest config tests for the vendor-only form, the default keyset + and the TTL; `MaybeZeroKms` tests. Go tests over the **real embedded + guest** through `NewClient`, with every `CS_*` variable cleared and `HOME` + in a temporary directory: a static key client with two records (no + default is `ErrNotFound`; the default by name and by id; v2 leaves; + `ErrNoZeroKMS` for an unknown keyset and for a ZeroKMS row), and an AWS + KMS client through the fake RoundTripper (no vendor call at NewClient; 4 + `Decrypt` calls for one Encrypt and two Decrypts with `WithRootKeyTTL(0)`; + 4 credential requests); and the option refusals. diff --git a/languages/golang/encrypt/client.go b/languages/golang/encrypt/client.go index 144b16a13..9d31a8d06 100644 --- a/languages/golang/encrypt/client.go +++ b/languages/golang/encrypt/client.go @@ -9,6 +9,7 @@ import ( "runtime" "strconv" "sync" + "time" "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/cipherstash/vitaminc/bindings/go/vcffi" @@ -87,8 +88,17 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) 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 @@ -106,7 +116,11 @@ 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) } @@ -114,6 +128,9 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) 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. @@ -171,6 +188,9 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) keysetCacheSize: cfg.keysetCacheSize, vendor: vendor, keysetRecords: cfg.keysetRecords, + vendorOnly: vendorOnly, + defaultKeyset: cfg.defaultKeyset, + rootKeyTTL: cfg.rootKeyTTL, }) if err != nil { return nil, err @@ -306,11 +326,19 @@ type initConfig struct { // 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") } @@ -332,12 +360,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)}) } - if cfg.vendor != nil { - fields = append(fields, cfg.vendor.configFields(cfg.keysetRecords)...) - } + 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 @@ -380,7 +426,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]. diff --git a/languages/golang/encrypt/doc.go b/languages/golang/encrypt/doc.go index 7d1d4724f..1baaedee3 100644 --- a/languages/golang/encrypt/doc.go +++ b/languages/golang/encrypt/doc.go @@ -145,12 +145,21 @@ // // # Key vendor backends // -// A client can hold one key vendor backend beside ZeroKMS: [AWSKMS], +// 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]. A keyset in a record goes to the vendor; every -// other keyset, and the default keyset, goes to ZeroKMS, so NewClient still -// needs ZeroKMS credentials. Select a vendor keyset by its record's name or +// 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. // @@ -167,7 +176,8 @@ // // wrapped data keys derived data keys // key lifetime index key: one settle; root key: cached in the guest -// data key: one value for 5 minutes; the rest derived +// data key: one value for 5 minutes (WithRootKeyTTL); +// the rest derived // vendor calls per 1 + one per sealed value 0, or 1 when the root key is // Encrypt or Decrypt (Vault: 1 + 1 per 250) not cached // the vendor can every call and every value the root key unwrap, at most diff --git a/languages/golang/encrypt/errors.go b/languages/golang/encrypt/errors.go index e37b6ca2a..912d46a11 100644 --- a/languages/golang/encrypt/errors.go +++ b/languages/golang/encrypt/errors.go @@ -62,6 +62,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/guest/src/abi.rs b/languages/golang/encrypt/guest/src/abi.rs index dd09d81b4..0f066d126 100644 --- a/languages/golang/encrypt/guest/src/abi.rs +++ b/languages/golang/encrypt/guest/src/abi.rs @@ -95,10 +95,11 @@ use stack_encrypt::ErrorPayload; /// and the host's credential import; with none configured it holds no /// keyset and every request goes to ZeroKMS. #[cfg(not(feature = "deterministic-kms"))] -type GuestZeroKms = - stack_kms::StackKms; +type GuestZeroKms = crate::zerokms::MaybeZeroKms< + stack_kms::StackKms, +>; #[cfg(feature = "deterministic-kms")] -type GuestZeroKms = crate::deterministic::DeterministicSource; +type GuestZeroKms = crate::zerokms::MaybeZeroKms; /// The instance's key vendor backend. type GuestVendor = crate::vendor::GuestVendor< @@ -210,6 +211,7 @@ pub unsafe extern "C" fn se_cipher_init(cfg_ptr: *mut u8, cfg_len: u32) -> u64 { fn cipher_init(decoded: FfiValue) -> Result, u32> { use crate::host::{HostTokenStrategy, WasiHostConnection}; use crate::status::STATUS_KMS_TRANSPORT; + use crate::zerokms::MaybeZeroKms; use stack_kms::{ClientOpts, StackKms}; let config = crate::config::parse_config(decoded).map_err(|e| malformed(e.describe()))?; @@ -217,34 +219,44 @@ fn cipher_init(decoded: FfiValue) -> Result, u32> { return Err(init_out_of_order()); } - // One request at a time: the host import is synchronous, so concurrency - // would only interleave nothing; keep the executor honest about it. - // - // `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 kms = stack_kms_vendor::KeyRouter::new(kms, build_vendor(config.vendor)?); + let vendor = build_vendor(config.vendor)?; + let kms = match config.client_key { + // 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 => { + stack_kms_vendor::KeyRouter::new(MaybeZeroKms::Absent, vendor).with_vendor_default() + } + Some(client_key) => { + // One request at a time: the host import is synchronous, so concurrency + // would only interleave nothing; keep the executor honest about it. + // + // `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, + ) + })?; + stack_kms_vendor::KeyRouter::new(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); @@ -292,8 +304,15 @@ fn cipher_init(decoded: FfiValue) -> Result, u32> { if SHUT_DOWN.with(Cell::get) || CIPHER.with(|c| c.borrow().is_some()) { return Err(init_out_of_order()); } + // The seeded source is this build's ZeroKMS, so a default keyset of the + // vendor's does not apply here. + if 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 = stack_kms_vendor::KeyRouter::new( - crate::deterministic::DeterministicSource::new(seed), + crate::zerokms::MaybeZeroKms::Present(crate::deterministic::DeterministicSource::new(seed)), build_vendor(vendor)?, ); let builder = StackCipher::builder().kms(kms); diff --git a/languages/golang/encrypt/guest/src/config.rs b/languages/golang/encrypt/guest/src/config.rs index 35c1032ab..8b9c5d2ef 100644 --- a/languages/golang/encrypt/guest/src/config.rs +++ b/languages/golang/encrypt/guest/src/config.rs @@ -27,6 +27,12 @@ //! | `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 derived data key 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, as an unknown key is. The vendor's credentials @@ -46,6 +52,7 @@ //! ABI layer immediately after decoding (see [`crate::abi`]). use std::num::NonZeroUsize; +use std::time::Duration; use stack_kms::{ClientKey, KeyVendor, ZeroKmsEndpoint}; use stack_kms_vendor::KeyBytes; @@ -54,7 +61,7 @@ use vitaminc_aead_value::FfiValue; use vitaminc_protected::Controlled; use zeroize::Zeroizing; -use crate::vendor::{VendorConfig, VendorSettings}; +use crate::vendor::{DefaultKeyset, VendorConfig, VendorSettings}; /// A parse failure, carrying which key was at fault. Maps to /// `STATUS_ENCODING` at the ABI, and [`describe`](Self::describe) is the @@ -83,6 +90,9 @@ pub enum ConfigError { /// A key vendor backend key that does not apply to the configured /// `key_vendor`, or that was given with no `key_vendor`. NotForVendor(&'static str), + /// A key that applies only to a client with a key vendor backend and no + /// ZeroKMS (`default_keyset`). + OnlyWithoutZeroKms(&'static str), } impl ConfigError { @@ -104,13 +114,19 @@ impl ConfigError { Self::NotForVendor(key) => { format!("the config's {key} does not apply to its key_vendor") } + Self::OnlyWithoutZeroKms(key) => { + format!("the config's {key} applies only to a client with no ZeroKMS") + } } } } /// 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. @@ -119,7 +135,7 @@ pub struct CipherConfig { /// The key vendor backend keys of the config, in the order the table above /// gives them. -const VENDOR_KEYS: [&str; 10] = [ +const VENDOR_KEYS: [&str; 12] = [ "key_vendor", "keyset_records", "aws_region", @@ -130,6 +146,8 @@ const VENDOR_KEYS: [&str; 10] = [ "gcp_endpoint", "static_key_ref", "static_key", + "default_keyset", + "root_key_ttl_ms", ]; /// Take the key vendor backend keys out of `entries` and parse them. The @@ -189,11 +207,25 @@ pub fn take_vendor_config( // client for is not one it can configure. _ => return Err(ConfigError::Invalid("key_vendor")), }; + let default_keyset = slots.default_keyset()?; + let root_key_ttl = slots + .string("root_key_ttl_ms")? + .map(|ms| { + ms.parse::() + .map(Duration::from_millis) + .map_err(|_| ConfigError::Invalid("root_key_ttl_ms")) + }) + .transpose()?; // Whatever is left was given for another backend. if let Some((key, _)) = slots.0.first() { return Err(ConfigError::NotForVendor(key)); } - Ok(Some(VendorConfig { settings, records })) + Ok(Some(VendorConfig { + settings, + records, + default_keyset, + root_key_ttl, + })) } /// The vendor keys of one config, taken one at a time. @@ -245,6 +277,32 @@ impl VendorSlots { .collect() } + /// `default_keyset`: a selector object, `{"id": 16 bytes}` or + /// `{"name": string}`. + fn default_keyset(&mut self) -> Result, ConfigError> { + const KEY: &str = "default_keyset"; + let Some(value) = self.take(KEY) else { + return Ok(None); + }; + let FfiValue::Object(mut entries) = value else { + return Err(ConfigError::WrongType(KEY)); + }; + let (Some((tag, value)), None) = (entries.pop(), entries.pop()) else { + return Err(ConfigError::Invalid(KEY)); + }; + match (tag.as_str(), value) { + ("id", FfiValue::Bytes(bytes)) => Uuid::from_slice(bytes.risky_ref()) + .map(|id| Some(DefaultKeyset::Id(id))) + .map_err(|_| ConfigError::Invalid(KEY)), + ("name", FfiValue::String(name)) => std::str::from_utf8(name.risky_ref()) + .ok() + .filter(|name| !name.is_empty()) + .map(|name| Some(DefaultKeyset::Name(name.to_owned()))) + .ok_or(ConfigError::Invalid(KEY)), + _ => Err(ConfigError::Invalid(KEY)), + } + } + fn static_key(&mut self) -> Result { const KEY: &str = "static_key"; let FfiValue::Bytes(bytes) = self.take(KEY).ok_or(ConfigError::Missing(KEY))? else { @@ -306,17 +364,34 @@ pub fn parse_config(value: FfiValue) -> Result { *slot = Some(text); } - let client_id = client_id.ok_or(ConfigError::Missing("client_id"))?; - let client_id = Uuid::parse_str(&client_id).map_err(|_| ConfigError::Invalid("client_id"))?; - - // Lenient by design: `from_encoded_v1` takes hex in either case *or* the - // base64 `secretkey.json` holds, matching every native loader. The - // `Zeroizing` slot wipes the encoded copy however this returns — the - // decoded `FfiValue`'s own copy was consumed above, and the raw input - // buffer is the ABI layer's to wipe. - let client_key_encoded = client_key_encoded.ok_or(ConfigError::Missing("client_key"))?; - let client_key = ClientKey::from_encoded_v1(client_id, &client_key_encoded) - .map_err(|_| ConfigError::Invalid("client_key"))?; + // 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() && client_id.is_none() && client_key_encoded.is_none() && url.is_none(); + let client_key = if vendor_only { + None + } else { + let client_id = client_id.ok_or(ConfigError::Missing("client_id"))?; + let client_id = + Uuid::parse_str(&client_id).map_err(|_| ConfigError::Invalid("client_id"))?; + + // Lenient by design: `from_encoded_v1` takes hex in either case *or* + // the base64 `secretkey.json` holds, matching every native loader. + // The `Zeroizing` slot wipes the encoded copy however this returns — + // the decoded `FfiValue`'s own copy was consumed above, and the raw + // input buffer is the ABI layer's to wipe. + let client_key_encoded = client_key_encoded.ok_or(ConfigError::Missing("client_key"))?; + Some( + ClientKey::from_encoded_v1(client_id, &client_key_encoded) + .map_err(|_| ConfigError::Invalid("client_key"))?, + ) + }; + // 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(ConfigError::OnlyWithoutZeroKms("default_keyset")); + } let endpoint = url .map(|u| u.parse().map_err(|_| ConfigError::Invalid("zerokms_url"))) @@ -381,7 +456,7 @@ mod tests { ("client_key", &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()); } @@ -463,9 +538,17 @@ mod tests { ] { let cfg = parse_config(obj(vec![("client_id", &id_s), ("client_key", &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" ); @@ -699,4 +782,90 @@ mod tests { Some(KeyVendor::Gcp) ); } + + #[test] + fn a_vendor_and_no_zerokms_key_is_a_vendor_only_client() { + let cfg = parse_config(FfiValue::Object(vec![ + ("key_vendor".into(), s("gcp-kms")), + ("keyset_records".into(), records()), + ( + "default_keyset".into(), + FfiValue::Object(vec![("name".into(), s("payments"))]), + ), + ("root_key_ttl_ms".into(), 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. + assert_eq!( + parse_config(FfiValue::Object(vec![ + ("key_vendor".into(), s("gcp-kms")), + ("keyset_records".into(), records()), + ("zerokms_url".into(), s("https://zerokms.example.com")), + ])) + .err(), + Some(ConfigError::Missing("client_id")) + ); + // No vendor and no ZeroKMS key is still a missing client id. + assert_eq!( + parse_config(FfiValue::Object(vec![])).err(), + Some(ConfigError::Missing("client_id")) + ); + } + + #[test] + fn a_default_keyset_needs_a_client_with_no_zerokms() { + let (id, hex) = client_key_hex(); + let mut entries = match obj(vec![("client_id", &id.to_string()), ("client_key", &hex)]) { + FfiValue::Object(entries) => entries, + _ => unreachable!(), + }; + entries.push(("key_vendor".into(), s("gcp-kms"))); + entries.push(("keyset_records".into(), records())); + entries.push(( + "default_keyset".into(), + FfiValue::Object(vec![( + "id".into(), + FfiValue::Bytes(vitaminc_protected::Protected::new(vec![1; 16])), + )]), + )); + assert_eq!( + parse_config(FfiValue::Object(entries)).err(), + Some(ConfigError::OnlyWithoutZeroKms("default_keyset")) + ); + + for bad in [ + s("payments"), + FfiValue::Object(vec![]), + FfiValue::Object(vec![("name".into(), s(""))]), + FfiValue::Object(vec![( + "id".into(), + FfiValue::Bytes(vitaminc_protected::Protected::new(vec![1; 15])), + )]), + FfiValue::Object(vec![("name".into(), s("a")), ("id".into(), s("b"))]), + ] { + assert!(vendor_config(vec![ + ("key_vendor", s("gcp-kms")), + ("keyset_records", records()), + ("default_keyset", bad), + ]) + .is_err()); + } + assert_eq!( + vendor_config(vec![ + ("key_vendor", s("gcp-kms")), + ("keyset_records", records()), + ("root_key_ttl_ms", s("-1")), + ]) + .unwrap_err(), + ConfigError::Invalid("root_key_ttl_ms") + ); + } } diff --git a/languages/golang/encrypt/guest/src/lib.rs b/languages/golang/encrypt/guest/src/lib.rs index a87c5908d..c3d428d78 100644 --- a/languages/golang/encrypt/guest/src/lib.rs +++ b/languages/golang/encrypt/guest/src/lib.rs @@ -100,6 +100,7 @@ pub mod response; pub mod status; pub mod targets; 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 4ce9d8a7d..008b74229 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 @@ -121,7 +121,8 @@ pub fn describe(cause: &(dyn std::error::Error + 'static)) -> Option { /// 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::() @@ -256,6 +257,8 @@ 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 diff --git a/languages/golang/encrypt/guest/src/vendor.rs b/languages/golang/encrypt/guest/src/vendor.rs index ffecede94..b7a7fd5ca 100644 --- a/languages/golang/encrypt/guest/src/vendor.rs +++ b/languages/golang/encrypt/guest/src/vendor.rs @@ -97,12 +97,25 @@ impl std::fmt::Debug for VendorSettings { } } -/// The key vendor backend part of the config: the backend and the keyset -/// record texts. +/// 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 @@ -487,7 +500,13 @@ where T: VendorTransport + Send + Sync, C: CredentialImport, { - let Some(VendorConfig { settings, records }) = config else { + let Some(VendorConfig { + settings, + records, + default_keyset, + root_key_ttl, + }) = config + else { return Ok(VendorKeySource::new(GuestVendor::None, [])?); }; let credentials = HostCredentials::new(import); @@ -547,7 +566,25 @@ where .iter() .map(|text| KeysetRecord::parse(text)) .collect::, _>>()?; - Ok(VendorKeySource::new(backend, records)?) + 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)] @@ -785,6 +822,8 @@ mod tests { endpoint: None, }, records: Vec::new(), + default_keyset: None, + root_key_ttl: None, } } @@ -844,6 +883,8 @@ mod tests { endpoint: None, }, records: Vec::new(), + default_keyset: None, + root_key_ttl: None, }; let error = build(Some(config), &Recording::default(), FakeImport::unused()).unwrap_err(); assert!(matches!(error, BuildError::Setting("aws_region"))); @@ -855,6 +896,8 @@ mod tests { mount: None, }, records: Vec::new(), + default_keyset: None, + root_key_ttl: None, }; let error = build(Some(config), &Recording::default(), FakeImport::unused()).unwrap_err(); assert!(matches!(error, BuildError::Setting("vault_address"))); @@ -870,6 +913,8 @@ mod tests { key: KeyBytes::new([7; 32]), }, records: vec![aws.to_owned()], + default_keyset: None, + root_key_ttl: None, }; let error = build(Some(config), &Recording::default(), FakeImport::unused()).unwrap_err(); assert!(matches!( diff --git a/languages/golang/encrypt/guest/src/zerokms.rs b/languages/golang/encrypt/guest/src/zerokms.rs new file mode 100644 index 000000000..1487bdad6 --- /dev/null +++ b/languages/golang/encrypt/guest/src/zerokms.rs @@ -0,0 +1,190 @@ +//! 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, a v1 leaf) 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, KeyMode, LoadedIndexKey, + RetrieveKeyPayload, SelectedKeyset, 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, +} + +/// 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 { + vendor: KeyBackend::ZeroKms.name(), + 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()), + } + } + + fn key_backend(&self) -> KeyBackend { + match self { + Self::Present(z) => z.key_backend(), + Self::Absent => KeyBackend::ZeroKms, + } + } + + fn keyset_backend(&self, keyset_id: Uuid) -> KeyBackend { + match self { + Self::Present(z) => z.keyset_backend(keyset_id), + Self::Absent => KeyBackend::ZeroKms, + } + } + + fn key_mode(&self, keyset_id: Uuid) -> Option { + match self { + Self::Present(z) => z.key_mode(keyset_id), + Self::Absent => None, + } + } + + async fn load_settle_index_key(&self, keyset_id: Uuid) -> Result { + match self { + Self::Present(z) => z.load_settle_index_key(keyset_id).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()), + } + } + + async fn load_index_key_with_lifetime( + &self, + keyset_id: Option, + ) -> Result { + match self { + Self::Present(z) => z.load_index_key_with_lifetime(keyset_id).await, + Self::Absent => Err(no_zerokms()), + } + } + + async fn select_keyset( + &self, + keyset_id: Option, + ) -> Result { + match self { + Self::Present(z) => z.select_keyset(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_settle_index_key(id)).unwrap_err(), + block(absent.load_index_key(None)).unwrap_err(), + block(absent.load_index_key_with_lifetime(None)).unwrap_err(), + block(absent.select_keyset(None)).unwrap_err(), + ] { + assert!(is_no_zerokms(&error), "{error:?}"); + } + assert_eq!(absent.key_backend(), KeyBackend::ZeroKms); + assert_eq!(absent.key_mode(id), None); + // Another backend error is not this one. + assert!(!is_no_zerokms(&Error::Backend { + vendor: KeyBackend::ZeroKms.name(), + 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(); + assert_eq!(present.keyset_backend(id), KeyBackend::ZeroKms); + } +} diff --git a/languages/golang/encrypt/keyvendor.go b/languages/golang/encrypt/keyvendor.go index 827e0f19b..232b2a6a7 100644 --- a/languages/golang/encrypt/keyvendor.go +++ b/languages/golang/encrypt/keyvendor.go @@ -207,10 +207,17 @@ func wipeVendorKey(v KeyVendor) { } } -// WithKeyVendor gives the client one key vendor backend beside ZeroKMS. It -// needs [WithKeysetRecords]; one without the other is refused. A later +// 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 { @@ -230,6 +237,63 @@ 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 under derived data keys, after which the next use unwraps it +// again: one vendor call, which the vendor can refuse. Zero keeps none: +// each use of a root key unwraps it, so an Encrypt that also derives search +// terms unwraps it twice. The default is 5 minutes. It has +// no effect on wrapped data keys, which keep nothing beyond one call. +// 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) { diff --git a/languages/golang/encrypt/keyvendor_test.go b/languages/golang/encrypt/keyvendor_test.go index 7f891082b..4e264f343 100644 --- a/languages/golang/encrypt/keyvendor_test.go +++ b/languages/golang/encrypt/keyvendor_test.go @@ -468,3 +468,152 @@ func TestVendorCredentialsDoNotPrintTheirSecrets(t *testing.T) { } } } + +// 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 != 2 { + t.Errorf("leaf format version %d, want 2", 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) + } + if n := kms.count("Decrypt"); n != 0 { + t.Errorf("NewClient made %d vendor calls, want 0", n) + } + roundTrip(t, c, c.DefaultKeyset()) + // The Encrypt uses the root key twice (the data keys, then the index + // key for the terms in the settle); each Decrypt 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, "; ")) + } + 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 96697292f..8b0916d19 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 @@ -28,6 +31,11 @@ type clientOptions struct { 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 diff --git a/languages/golang/encrypt/transport.go b/languages/golang/encrypt/transport.go index 9e0ea91a8..f5ceb2462 100644 --- a/languages/golang/encrypt/transport.go +++ b/languages/golang/encrypt/transport.go @@ -221,6 +221,12 @@ func (b *requestBody) Close() error { // tokenGet is token_get: hand the guest the current bearer token. 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, err := t.token.Token(ctx) if err != nil { t.tokenErr = err diff --git a/languages/golang/internal/guest/errors.go b/languages/golang/internal/guest/errors.go index 347ec4609..c6f00c480 100644 --- a/languages/golang/internal/guest/errors.go +++ b/languages/golang/internal/guest/errors.go @@ -86,6 +86,9 @@ var ( // ErrContextMismatch is a row whose stored context field is not the // context the caller named, refused before any key is retrieved. ErrContextMismatch = errors.New("cipherstash: the stored context is not the one expected") + // 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 130834b05..14f0ef258 100644 --- a/languages/golang/internal/guest/status.go +++ b/languages/golang/internal/guest/status.go @@ -37,6 +37,7 @@ const ( StatusAuthRefreshRequired = 27 // Appended by the crypto guest after the credential guest's codes. StatusContextMismatch = 28 + StatusNoZeroKMS = 29 ) // StatusError is the sentinel a guest status decodes to. A status this host @@ -100,6 +101,8 @@ func StatusError(status uint32) error { return ErrAuthRefreshRequired case StatusContextMismatch: return ErrContextMismatch + case StatusNoZeroKMS: + return ErrNoZeroKMS default: return unrecognizedStatus(status) } diff --git a/packages/stack-guest-abi/src/status.rs b/packages/stack-guest-abi/src/status.rs index a62ba51c3..a6cef4f0f 100644 --- a/packages/stack-guest-abi/src/status.rs +++ b/packages/stack-guest-abi/src/status.rs @@ -122,10 +122,16 @@ pub const STATUS_AUTH_REFRESH_REQUIRED: u32 = 27; /// and only that — never a verdict on the record's integrity. pub const STATUS_CONTEXT_MISMATCH: u32 = 28; +/// 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 = 29; + /// 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_CONTEXT_MISMATCH; +pub const LAST_STATUS: u32 = STATUS_NO_ZEROKMS; #[cfg(test)] mod tests { @@ -164,6 +170,7 @@ mod tests { STATUS_AUTH_OTHER, STATUS_AUTH_REFRESH_REQUIRED, STATUS_CONTEXT_MISMATCH, + 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-kms-vendor/src/router.rs b/packages/stack-kms-vendor/src/router.rs index 43db17b78..c4cb550c0 100644 --- a/packages/stack-kms-vendor/src/router.rs +++ b/packages/stack-kms-vendor/src/router.rs @@ -27,7 +27,8 @@ use crate::vendor::KeyVendorBackend; /// * A keyset name that one of the vendor's records has goes to the vendor. /// A local record name wins over a ZeroKMS keyset of the same name. Every /// other name goes to ZeroKMS. -/// * Naming no keyset is ZeroKMS's default keyset. +/// * Naming no keyset is ZeroKMS's default keyset, or the vendor's default +/// keyset after [`with_vendor_default`](Self::with_vendor_default). /// /// The cipher reads [`keyset_backend`](DataKeySource::keyset_backend) to /// refuse a leaf that names the wrong backend before any key call, and to @@ -36,6 +37,7 @@ use crate::vendor::KeyVendorBackend; pub struct KeyRouter { zerokms: Z, vendor: VendorKeySource, + vendor_default: bool, } impl std::fmt::Debug for KeyRouter { @@ -43,6 +45,7 @@ impl std::fmt::Debug for KeyRouter { f.debug_struct("KeyRouter") .field("zerokms", &std::any::type_name::()) .field("vendor", &self.vendor) + .field("vendor_default", &self.vendor_default) .finish() } } @@ -50,7 +53,21 @@ impl std::fmt::Debug for KeyRouter { impl KeyRouter { /// Route between `zerokms` and `vendor`. pub fn new(zerokms: Z, vendor: VendorKeySource) -> Self { - Self { zerokms, vendor } + Self { + zerokms, + vendor, + vendor_default: false, + } + } + + /// Make naming no keyset select the vendor's default keyset (its only + /// record, or the one set with + /// [`VendorKeySource::with_default_keyset`]), not ZeroKMS's. For a + /// client that has a key vendor backend and no ZeroKMS credentials: its + /// ZeroKMS side is then a source that refuses every request. + pub fn with_vendor_default(mut self) -> Self { + self.vendor_default = true; + self } /// The ZeroKMS key source. @@ -64,12 +81,15 @@ impl KeyRouter { } fn is_vendor_id(&self, keyset: Option) -> bool { - keyset.is_some_and(|keyset| self.vendor.has_keyset(keyset)) + match keyset { + None => self.vendor_default, + Some(keyset) => self.vendor.has_keyset(keyset), + } } fn is_vendor_selection(&self, keyset: Option<&IdentifiedBy>) -> bool { match keyset { - None => false, + None => self.vendor_default, Some(IdentifiedBy::Uuid(id)) => self.vendor.has_keyset(*id), Some(IdentifiedBy::Name(name)) => self.vendor.has_name(name), } @@ -115,10 +135,15 @@ where } } - /// ZeroKMS: the backend of the default keyset. Per keyset, see + /// The backend of the default keyset: ZeroKMS, or the vendor after + /// [`with_vendor_default`](Self::with_vendor_default). Per keyset, see /// [`keyset_backend`](DataKeySource::keyset_backend). fn key_backend(&self) -> KeyBackend { - self.zerokms.key_backend() + if self.vendor_default { + self.vendor.key_backend() + } else { + self.zerokms.key_backend() + } } fn keyset_backend(&self, keyset_id: Uuid) -> KeyBackend { diff --git a/packages/stack-kms-vendor/tests/router.rs b/packages/stack-kms-vendor/tests/router.rs index 3ab03097c..784ca9bfd 100644 --- a/packages/stack-kms-vendor/tests/router.rs +++ b/packages/stack-kms-vendor/tests/router.rs @@ -293,3 +293,33 @@ async fn several_records_need_a_named_default() { Err(RecordError::UnknownDefault) )); } + +/// A client with a key vendor backend and no ZeroKMS: naming no keyset is +/// the vendor's default keyset, and anything else goes to the ZeroKMS side. +#[tokio::test] +async fn a_vendor_default_router_selects_the_vendor_for_no_keyset() { + let vendor = FakeVendor::new(KeyVendor::Aws); + let record = KeysetRecord::create(&vendor, FakeVendor::KEY_REF, KeyMode::Derived, None) + .await + .unwrap(); + let source = VendorKeySource::new(vendor, [record.clone()]).unwrap(); + let router = KeyRouter::new(FakeDataKeySource::new(), source).with_vendor_default(); + assert_eq!( + stack_encrypt::kms::DataKeySource::key_backend(&router), + KeyBackend::Vendor(KeyVendor::Aws) + ); + let cipher = StackCipher::builder().kms(router).init().await.unwrap(); + assert_eq!(cipher.default_keyset().keyset_id(), record.id()); + let context = nonempty!("users").with("email"); + let sealed = cipher + .default_keyset() + .encrypt("v".to_string(), context) + .await + .unwrap(); + assert_eq!( + leaf_backend(&sealed), + (KeyBackend::Vendor(KeyVendor::Aws), Some(KeyMode::Derived)) + ); + let opened: String = cipher.decrypt(sealed, context).await.unwrap(); + assert_eq!(opened, "v"); +} From 40e152783cb3e5f0762eac7606755b35ad93fbbd Mon Sep 17 00:00:00 2001 From: James Sadler Date: Fri, 6 Nov 2026 16:12:36 +1100 Subject: [PATCH 5/5] fix(go): hide the StaticKey key from fmt and slog (CIP-5311) StaticKey had no String, GoString or LogValue, so a %+v or a slog of the value printed the root key. Add the three methods, as AWSCredentials and AccessToken have, and a test for each output. --- languages/golang/encrypt/keyvendor.go | 13 +++++++++++++ languages/golang/encrypt/keyvendor_test.go | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/languages/golang/encrypt/keyvendor.go b/languages/golang/encrypt/keyvendor.go index 232b2a6a7..faced52f8 100644 --- a/languages/golang/encrypt/keyvendor.go +++ b/languages/golang/encrypt/keyvendor.go @@ -179,6 +179,19 @@ type StaticKey struct { 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) diff --git a/languages/golang/encrypt/keyvendor_test.go b/languages/golang/encrypt/keyvendor_test.go index 4e264f343..93d07dea7 100644 --- a/languages/golang/encrypt/keyvendor_test.go +++ b/languages/golang/encrypt/keyvendor_test.go @@ -9,10 +9,12 @@ import ( "crypto/sha256" "encoding/base64" "encoding/binary" + "encoding/hex" "encoding/json" "errors" "fmt" "io" + "log/slog" "net/http" "reflect" "strings" @@ -469,6 +471,21 @@ func TestVendorCredentialsDoNotPrintTheirSecrets(t *testing.T) { } } +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) {