Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions docs/plans/2026-11-05-key-vendor-backends-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1744,13 +1744,81 @@ 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<Z, B>` 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[_<NAME>]`, `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
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
Expand Down Expand Up @@ -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.
34 changes: 24 additions & 10 deletions languages/golang/encrypt/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
29 changes: 19 additions & 10 deletions languages/golang/encrypt/guest/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions languages/golang/encrypt/keyvendor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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{
Expand Down
5 changes: 5 additions & 0 deletions languages/golang/encrypt/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
8 changes: 5 additions & 3 deletions languages/golang/encrypt/step.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading
Loading