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 6796faaa1..99381f80d 100644 --- a/docs/plans/2026-11-05-key-vendor-backends-decisions.md +++ b/docs/plans/2026-11-05-key-vendor-backends-decisions.md @@ -1744,6 +1744,68 @@ the alternatives and the reason. until a restart or a failed wrap. Documented; a TTL on the cached version would close it. +## Review after CIP-5313 + +A review of the whole stack found three claims that the code does not hold, +and work that no ticket asked for. This section records them. + +### Claims that were not true + +- **Key material crosses to the Go host (CIP-5203, CIP-5311, ADR-0011).** + The guest sends each vendor request through `transport_send`, and the host + does the TLS. So the host, and a `WithTransport` RoundTripper, see in + plaintext the key a wrap request sends and the key that an unwrap or + generate response returns: AWS `Decrypt` and `GenerateDataKey`, Google + Cloud KMS and Vault `decrypt`, Azure `unwrapkey`. That is a root key under + derived data keys and a data key under wrapped data keys, and it is usable + alone. With ZeroKMS the property held, because a response alone is not + usable. `transport.go` wipes the bodies it holds, and its comment said + "wrapped key material", which was wrong for a vendor. The buffers of + net/http, TLS and the RoundTripper are not wiped. CIP-5311 reworded + `guest/src/host.rs` to a narrower claim without saying so. + **Decision:** keep the design and record the fact. ADR-0011 is amended, + and the Go package docs (`doc.go`, `KeyVendor`, `WithTransport`), + `host.rs` and `transport.go` now say what the host sees. **Alternative:** + TLS inside the guest. **Reason:** it is a large change (a TLS stack and a + socket import in the guest) and is not planned. +- **Long-lived credentials can cross to the guest (CIP-5311).** The + credential import carries what the Go source returns. + `AWSCredentialsFromEnv` passes an IAM user's long-lived keys on each + request when no session token is set, and a Vault token can be + long-lived. `StaticKey.Key` crosses from the host to the guest once, in + the guest config. **Decision:** record it. `AWSCredentialsFromEnv` and + `StaticKey` say so in their docs, and the package docs no longer say + that only short-lived credentials cross. +- **"The type holds exactly one vendor" (CIP-5304, CIP-5306).** Not true when + written: `KeyRouter` took any `DataKeySource` as `Z`, so a router + could hold a second router. **Fixed:** the ZeroKMS side must now implement + the marker `stack_kms::ZeroKmsKeySource`, which no router implements. See + the CIP-5306 entry. + +### Work beyond what the tickets asked + +Each item below is recorded where it was decided. This list puts them in one +place for review. + +- **New public napi exports** `keysetCreate` and `keysetRewrap` on + `@cipherstash/auth` (CIP-5312). Every `@cipherstash/stack` install now + ships the vendor crates in that binary (about 11% larger). This settles the + CIP-5306 open item, but it is a public npm surface next to the TypeScript + work that CIP-5314 holds out of scope. +- **A Go client with no ZeroKMS** (`STATUS_NO_ZEROKMS`, `ErrNoZeroKMS`, + `WithDefaultKeyset`, `WithRootKeyTTL`), an orchestrator decision in + CIP-5311. The goal of the stack supports it. +- **A behaviour change in a docs ticket:** `RecordError::RewrapToAnotherKey` + and `REWRAP_REFUSED` (CIP-5313). It fixes a data-loss defect, so it stays. +- **CLI flags and variable names that no ticket named** (CIP-5312): + `--key-env`, `--new-key-env`, `--in`, `--out`, `--force` and `--json` on + `stash keyset`, and the variable names it prints + (`CS_KEYSET_RECORD[_]`, `CS_STATIC_KEY`). +- **Azure workload identity** (CIP-5309), beside the client secret and + managed identity credentials. +- **A local record name wins over a ZeroKMS keyset of the same name** + (`KeyRouter`, CIP-5306). + ## Summary for review Open items first, then items that a later layer closed. Each line names the @@ -1751,6 +1813,12 @@ ticket that recorded it. **Open** +- **Review after CIP-5313 (recorded, by design):** with a key vendor + backend, the Go host and its RoundTripper see unwrapped vendor keys in + plaintext. ADR-0011 is amended. +- **Review after CIP-5313 (recorded):** long-lived credentials can cross to + the Go guest (`AWSCredentialsFromEnv` with no session token, a fixed Vault + token, `StaticKey`). - **CIP-5313 (OPEN):** the docs site (cipherstash.com/docs) has nothing on key vendor backends or `stash keyset`. - **CIP-5313 (Revisit):** Azure caches a key's current version for the life @@ -1825,3 +1893,63 @@ ticket that recorded it. now refuses it (`RecordError::RewrapToAnotherKey`; `REWRAP_REFUSED` in `@cipherstash/auth`; `rewrap_refused` in `stash keyset rewrap`), and allows a new version of the same key (`KeyVendorBackend::same_key`). + +## Rebase onto `next` (2026-11-09) + +The stack was based on `main`. It is now based on `next`, which had +changed three things the stack builds on. The decisions below were made +during the rebase, and the commits they changed are named. + +### JSON terms under a keyset with no PRF (CIP-5306) + +- **Context:** `next` added the JSON index (documents, `JsonSearch` + selectors, subtree reads) and block ORE, and each derived under + `KeysetCipher::prf()` when the request was planned. CIP-5306 makes that + `Option`: a vendor keyset that loads its index key per settle holds no + PRF between settles. +- **Decision:** derive inside the settle, as the other terms do. A document + is walked in the settle that mints its data key; a query's terms, a + selector and a subtree read key are derived in a settle that loads the + index key; a read's row selectors take the PRF of the open's settle + (`Selectors::in_settle`). A keyset request answered with a per-settle + keyset loads its index key in the same settle. Under ZeroKMS nothing + changes: the terms are derived when the request is planned, as before. +- **Trade-off:** the dynamic read (`json_open`) opens all its row groups in + one settle under a per-settle keyset. There, an entry that carries another + selector is refused after its data key is retrieved, not before, because + the selector is not known before the settle. +- **Also:** `sem::capture` (the borrowed-value capture for equality terms + derived in the settle) was removed: `next` normalizes an equality input to + an owned `Normalized` first, which the settle can hold. Its test commit + was dropped with it. + +### The guest's vendor path moved onto the step loop (CIP-5311) + +- **Context:** `next` removed the blocking `transport_send` import from the + encrypt guest. An export that needs the network pauses, the Go host + performs the request outside the client's lock, and resumes it. +- **Decision:** a step request is now one of three: an HTTP request, a key + vendor credential (vendor byte and OAuth scope), or a wait (a retry's + backoff). The guest's `StepVendorTransport` sends and sleeps through the + step loop, and `StepCredentialImport` asks for credentials through it. + `vendor_credential_get` is gone; `token_get` is still the one import. +- **Go host:** `performStep` answers a credential request from the + backend's source, outside the lock and under the caller's context, once + per operation, vendor and scope (the guest still asks before each + request and keeps nothing). It attaches the source's error to the call's + error. It sits out a wait under the caller's context, cut to 30 seconds. +- **Also:** `next` reads the guest config with serde, so the key vendor + backend keys are `Wire` fields, and the deterministic build reads them in + an object beside its seed. `STATUS_NO_ZEROKMS` is 30, because `next` + gave 29 to `STATUS_NEEDS_IO`. ADR-0011 has an amendment for the step + loop. + +### Smaller changes + +- The keyset handle's `encrypt(value, aad)` and `decrypt(value, aad)` are + `encrypt_tree` and `decrypt_tree` on `next`; the stack's tests use them. +- The stack's crates now require `stack-encrypt` 0.3.0 and `stack-kms` + 0.2.0, the versions on `next`. +- The pinned v1 leaf in `tests/frozen_bytes.rs` was sealed with `main`'s + plaintext encoding. `next` puts each leaf's type in its plaintext, so it + was sealed again. diff --git a/languages/golang/encrypt/doc.go b/languages/golang/encrypt/doc.go index df7e10bae..13b0868d9 100644 --- a/languages/golang/encrypt/doc.go +++ b/languages/golang/encrypt/doc.go @@ -317,11 +317,14 @@ // operation that needs ZeroKMS or the key vendor backend pauses and hands // the request to the client, which sends it with any // [net/http.RoundTripper] and resumes the operation with the response. A -// key vendor backend's short-lived credential, from the source given with +// key vendor backend's credential, from the source given with // [WithKeyVendor], and a retry's backoff are handed to the client the same -// way, so the guest never waits on the host. What crosses per ZeroKMS or -// vendor call is what would cross TLS anyway; derived and unwrapped key -// material never leaves the guest. Under +// way. What crosses per ZeroKMS call is what would cross TLS anyway, and +// keys derived from a ZeroKMS response never leave the guest: a response +// alone is not usable. A key vendor backend is different: the host does the +// TLS, so the host and the RoundTripper see each vendor request and +// response in plaintext, and those carry unwrapped keys (see [KeyVendor]). +// Under // [AutoCredentials] and [OIDCFederation] the same RoundTripper also carries // the authentication requests to CTS, so one scoped to the ZeroKMS host alone // is not enough. Under [NewCredentials] those requests go through the store @@ -364,12 +367,23 @@ // 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. +// The guest builds and signs every vendor request. The host supplies the +// credentials, per request: AWS credentials, a Google OAuth access token, an +// Azure access token for the scope the guest names, or a Vault token. Fill +// them from the vendor's own Go credential chain through +// [AWSCredentialsFunc], [AccessTokenFunc] or [OAuth2AccessTokens], which +// give short-lived credentials. [AWSCredentialsFromEnv] and a fixed Vault +// token can give long-lived ones, and those cross to the guest on each +// request too. This package imports no vendor SDK. +// +// The host sends the vendor requests and does their TLS. So the host, and +// the RoundTripper given with [WithTransport], see in plaintext the key that +// a wrap request sends and the key that an unwrap or generate response +// returns: a root key under derived data keys, a data key under wrapped data +// keys. Unlike a ZeroKMS response, such a key is usable alone. The client +// wipes the request and response bodies it holds, but not the buffers of +// net/http, TLS or the RoundTripper. Give WithTransport only a RoundTripper +// you trust with key material. // // 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 diff --git a/languages/golang/encrypt/guest/src/host.rs b/languages/golang/encrypt/guest/src/host.rs index a29869c0e..f3036f4f3 100644 --- a/languages/golang/encrypt/guest/src/host.rs +++ b/languages/golang/encrypt/guest/src/host.rs @@ -37,16 +37,25 @@ //! 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* for a credential step is a short-lived -//! credential (temporary AWS credentials, an access token, a Vault token), -//! never a long-lived signing key, which the guest uses to sign and then -//! drops. +//! A key vendor backend does **not** keep that property. A vendor request +//! crosses as the signed HTTP request the vendor would receive over TLS, +//! and the host does the TLS. So the host, and any RoundTripper the Go +//! caller gives it, see the vendor traffic in plaintext: the key a wrap +//! request sends, and the key that an unwrap or generate response returns +//! (AWS `Decrypt` and `GenerateDataKey`, Google Cloud KMS and Vault +//! `decrypt`, Azure `unwrapkey`). That is a root key under derived data +//! keys, or a data key under wrapped data keys. Unlike a ZeroKMS response, +//! it is usable alone. The host wipes the request and response bodies it +//! holds once they are in guest memory, but not the buffers of net/http, +//! TLS or the RoundTripper. Keys the guest derives from it (index keys, +//! derived data keys) never cross back. +//! +//! What crosses *in* for a credential step is the credential the host's +//! source returns, which the guest uses to sign and then drops. It is +//! short-lived when the source is a role or token chain, and long-lived +//! when the source returns fixed keys (Go's `AWSCredentialsFromEnv` with no +//! session token, a fixed Vault token). A `StaticKey` backend's key crosses +//! once, in the guest config. //! //! Request bodies and response bodies can carry key-material contexts and //! wrapped keys, so both are wiped on drop here; the buffers the host wrote diff --git a/languages/golang/encrypt/keyvendor.go b/languages/golang/encrypt/keyvendor.go index 571197ce5..946adfcfa 100644 --- a/languages/golang/encrypt/keyvendor.go +++ b/languages/golang/encrypt/keyvendor.go @@ -24,10 +24,10 @@ import ( // 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. +// RoundTripper ([WithTransport]) sends them, so the host sees the keys that +// those requests and responses carry in plaintext. See the package +// documentation for what crosses to the host, and for what each backend and +// key mode can and cannot do. // // The interface is sealed: the five types above are its only // implementations. @@ -192,7 +192,8 @@ func (v VaultTransit) keyVendor() (*vendorSpec, error) { // 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. +// memory. Nothing outside the process can refuse or audit its use. The key +// crosses from the host to the guest once, when NewClient starts. type StaticKey struct { // KeyRef is the key's reference, as the keyset records name it. // Required. @@ -426,6 +427,10 @@ func (f AWSCredentialsFunc) AWSCredentials(ctx context.Context) (AWSCredentials, // AWS_SESSION_TOKEN on each request. Without the first two it fails with // [ErrNoKeyVendorCredentials]. For anything else (a role, IRSA, IMDS, SSO) // use the AWS SDK's chain through [AWSCredentialsFunc]. +// +// With no AWS_SESSION_TOKEN these are usually the long-lived keys of an IAM +// user, and they cross to the guest on each request. Prefer the temporary +// credentials of a role, through [AWSCredentialsFunc]. func AWSCredentialsFromEnv() AWSCredentialSource { return AWSCredentialsFunc(func(context.Context) (AWSCredentials, error) { c := AWSCredentials{ diff --git a/languages/golang/encrypt/options.go b/languages/golang/encrypt/options.go index 8b0916d19..42488e601 100644 --- a/languages/golang/encrypt/options.go +++ b/languages/golang/encrypt/options.go @@ -73,6 +73,11 @@ func WithKeysetCacheSize(n int) ClientOption { // [NewCredentials] the token exchange runs in the store the caller opened, // not through this RoundTripper: pass auth.WithRoundTripper to that // store instead. Nil means http.DefaultTransport, the default. +// +// With a key vendor backend ([WithKeyVendor]) the RoundTripper also sends +// the vendor requests, and it sees the keys that they and their responses +// carry in plaintext. Give it only a RoundTripper you trust with key +// material. func WithTransport(rt http.RoundTripper) ClientOption { return func(o *clientOptions) { o.transport = rt } } diff --git a/languages/golang/encrypt/step.go b/languages/golang/encrypt/step.go index 7a53f8117..e3ce33c6d 100644 --- a/languages/golang/encrypt/step.go +++ b/languages/golang/encrypt/step.go @@ -244,9 +244,11 @@ func (op *operation) end() { // performStep performs every request of a step, in order, and encodes the // responses for se_resume: `[{"status": i32, "headers": bytes, "body": -// bytes}, ...]`. The responses can carry wrapped keys or a credential: the -// caller wipes the encoding once the guest has it, and every other copy is -// wiped here. +// bytes}, ...]`. The responses can carry key material (a ZeroKMS response, +// or from a key vendor backend an unwrapped key in plaintext) or a +// credential: the caller wipes the encoding once the guest has it, and +// every other copy this side holds is wiped here. The copies in net/http, +// TLS and the RoundTripper are not. func (t *transport) performStep(ctx context.Context, step *ioStep, op *operation) ([]byte, error) { defer step.wipe() responses := make([]any, 0, len(step.requests)) diff --git a/packages/stack-encrypt/docs/adr/0011-hand-written-vendor-clients-signed-in-the-guest.md b/packages/stack-encrypt/docs/adr/0011-hand-written-vendor-clients-signed-in-the-guest.md index 2520d1586..296117339 100644 --- a/packages/stack-encrypt/docs/adr/0011-hand-written-vendor-clients-signed-in-the-guest.md +++ b/packages/stack-encrypt/docs/adr/0011-hand-written-vendor-clients-signed-in-the-guest.md @@ -5,6 +5,50 @@ date: 2026-11-04 # Hand-written vendor clients, signed in the guest +> **Amended 2026-11-09**, when the stack moved onto the step loop of the +> encrypt guest (the guest no longer imports `transport_send`, and never +> waits on the network). The decision is unchanged. How the guest reaches +> the host changed: +> +> 1. **A vendor request is a step.** The operation pauses with the signed +> request, and the host performs it between steps, as it performs a +> ZeroKMS request. The host still does the TLS, so amendment 1 below still +> holds. +> 2. **A credential is a step, not an import.** The operation pauses with a +> credential request (the vendor and the OAuth scope). The Go host answers +> it from the vendor's credential source outside the client's lock, once +> per operation, vendor and scope. There is no `vendor_credential_get` +> import; `token_get` is the guest's one import. +> 3. **A retry's backoff is a step.** The host waits it out under the +> caller's context, cut to 30 seconds, with the client free for other +> callers. + +> **Amended 2026-11-06**, after the review of the key vendor backend stack. +> The decision is unchanged. Two of its claims were not true, and are +> corrected here. +> +> 1. **Key material crosses to the host.** The guest hands each vendor +> request to the host, and the host does the TLS. So the Go +> host, and any `WithTransport` RoundTripper, see the vendor traffic in +> plaintext: the key a wrap request sends, and the key that an unwrap or +> generate response returns (AWS `Decrypt` and `GenerateDataKey`, Google +> Cloud KMS and Vault `decrypt`, Azure `unwrapkey`). That key is a root +> key under derived data keys and a data key under wrapped data keys, and +> it is usable alone. A ZeroKMS response is not, which is why the property +> held for ZeroKMS. The host wipes the bodies it holds, but not the +> buffers of net/http, TLS or the RoundTripper. Keys the guest derives +> from it still never cross back. Keeping vendor keys from the host needs +> TLS inside the guest; that is not planned. +> 2. **Long-lived credentials can cross to the guest.** The credential step +> carries whatever the Go source returns. `AWSCredentialsFromEnv` returns +> an IAM user's long-lived keys when no session token is set, and a Vault +> token can be long-lived. A `StaticKey` backend's key crosses once, in +> the guest config. The guest still does no RSA and reads no signing key +> file. +> +> The Go package documentation, `guest/src/host.rs` and `transport.go` now +> say this. + The Go SDK reaches Stack Encrypt through a WASI guest. The guest builds the ZeroKMS requests and derives the keys. The Go host is only an HTTP pipe (`transport_send`) and a token source, and no key material crosses back to the @@ -22,7 +66,8 @@ Azure AD token, the Vault token). It gets short-lived signing credentials from the Go host through a new credential import, of the same shape as `token_get`. The host fills the import from the vendor's own Go credential chain, so we do not implement IMDS, the GCP metadata server or Azure managed identity in the -guest. Credentials cross the boundary. Key material does not. +guest. Credentials cross the boundary. Key material derived in the guest +does not, but the vendor's own traffic does (see the amendment). The Go module ships one guest that contains all vendor clients, not one guest for each vendor. This decision depends on a measurement of the guest size. @@ -33,8 +78,11 @@ for each vendor. This decision depends on a measurement of the guest size. Rejected. There would be two code paths for each vendor, and the guest path would be the less tested one. - **The Go host calls the vendor's Go SDK and passes the unwrapped key to the - guest.** Rejected. It breaks the rule that key material never crosses to the - host. + guest.** Rejected. The host would parse each unwrapped key itself, and each + vendor would need a Go client as well as a Rust one. (This record first + said the option broke the rule that key material never crosses to the + host. The chosen design does not keep that rule either; see the + amendment.) - **One guest for each vendor.** Rejected for now. It multiplies the embedded `.wasm` files, the deterministic test builds and the release workflow by up to five.