Skip to content

docs(stack-encrypt): key vendor backend design (CIP-5203) - #1624

Open
freshtonic wants to merge 1 commit into
nextfrom
james/cip-5203-key-vendor-backend-design
Open

freshtonic wants to merge 1 commit into
nextfrom
james/cip-5203-key-vendor-backend-design

Conversation

@freshtonic

@freshtonic freshtonic commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Scope (2026-11-12). The stack is: #1624 (design) → #1632 (stack-kms) → #1625 (stack-kms-vendor) → #1626 (AWS) → #1627 (Vault) → #1628 (GCP) → #1629 (Azure) → #1630 (Go). #1631 (CLI) is a separate PR on #1629. stack-encrypt does not change. Until #1631 merges, a keyset record can be made only from Rust (KeysetRecord::create), so the Rust crates of this stack are not usable by a customer alone.

Records the design for CIP-5203: Stack Encrypt with AWS KMS, Azure Key Vault, HashiCorp Vault Transit, Google Cloud KMS and a static key, as alternatives to ZeroKMS.

This PR changes docs only. It changes no code.

  • ADR-0013: derived data keys for key vendor backends. A vendor-wrapped root key is cached for a TTL, and each data key and the index key are derived from it. Vendor calls are O(keysets), not O(values). Search is required on every backend.
  • ADR-0014: hand-written vendor REST clients, used by native Rust and the Go WASI guest. The guest signs requests with credentials that the host supplies. The vendor traffic, which holds the root key, crosses to the host.
  • CONTEXT.md: widens the Keyset term so it is not ZeroKMS-only. Adds these terms: key backend, key vendor backend, keyset record, derived data key, root key, settle.
  • Decision log: docs/plans/2026-11-05-key-vendor-backends-decisions.md.

Implementation is tracked in the sub-issues of CIP-5203 (CIP-5304 to CIP-5313). The follow-ups are CIP-5314 (TypeScript) and CIP-5315 (migration from a vendor to ZeroKMS).

ADR-0013 (derived data keys for key vendor backends) and ADR-0014
(hand-written vendor clients signed in the guest), the domain terms, and
the decision log.
@freshtonic
freshtonic requested a review from a team as a code owner November 12, 2026 05:27
@coderabbitai

coderabbitai Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI; Enforced overrides: Organization
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 2da4da27-dfc3-4e9b-9937-400383f77bc3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review

@changeset-bot

changeset-bot Bot commented Nov 12, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9a17fe4

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@cipherstash-agent cipherstash-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review attestation

Reviewed 9a17fe4 · posted from Claude Code (scheduled routine) · claude-opus-5-5 · effort medium
Sources: code-review · claude-opus-5-5; rust-security · claude-opus-5-5
Skills: code-review, rust-security, review-cross-check, review-post

Blocking (1)

  • docs/plans/2026-11-05-key-vendor-backends-decisions.md:849 Azure RSA-OAEP wrap lets anyone with the public key forge a record's root key

Findings (10): 1 critical, 9 minor — 10 inline, 0 below.

This PR is docs-only; findings were checked against the design text at 9a17fe4 (and next where the docs cite code). No existing review feedback carried findings, so none are duplicates.


Generated by Claude Code

versioned one pins wraps only. Size: 115 bytes for A256KW, 291 bytes for
RSA-OAEP-256 with a 2048-bit key. **Alternative:** store the full versioned
`kid` (60 to 100 bytes more per record, and it repeats the key reference).
- **Decision (no AAD):** the client **always** wraps the bound key of

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking · critical RSA-OAEP-256 is public-key encryption, so the keyless SHA-256(frame[bound-key/v1, aad]) in the bound key gives no authenticity: anyone holding the Key Vault public key and write access to a record can encrypt 1 ‖ digest ‖ K_attacker ‖ 0×7 locally, and unwrapkey plus the digest check accept it as the root key, with no vendor permission or audit event. Non-malleability (the argument at lines 438-440 and here) only stops edits to an existing ciphertext; the same gap affects the guidance at line 120-122 and "the vendor key itself is the binding" at line 137. Refuse RSA/RSA-HSM keys (A256KW on oct-HSM only) or authenticate the binding with key material Azure holds.

Source: rust-security · claude-opus-5-5


Generated by Claude Code

router already forwards every provided method.
- **Decision (additive, stack-kms-vendor):** `KeyRouter::with_vendor_default`
sends "no keyset named" to the vendor. A router test covers it.
- **Decision:** a new status, `STATUS_NO_ZEROKMS = 29` (`LAST_STATUS` moves),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor STATUS_NO_ZEROKMS = 29 and the vendor_credential_get import (lines 1024-1030, 1092-1095) are superseded by the rebase section (line 1616: status is 30 because next gave 29 to STATUS_NEEDS_IO; the import is gone), but these entries are not marked. A reader who takes 29 from this entry collides with STATUS_NEEDS_IO (packages/stack-guest-abi/src/status.rs:129 on next); mark lines 1024, 1092, 1196 and 1463 as superseded.

Source: code-review · claude-opus-5-5


Generated by Claude Code

(`with_scope` changes it). **Alternative:** a self-signed JWT sent
directly as the bearer token (no exchange). **Reason:** the ticket names
the exchange, and it works for every key the token endpoint accepts.
- **Decision (RSA crate):** RS256 uses the `rsa` crate 0.9.10 (RustCrypto,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor rsa was chosen because ring/aws-lc-rs do not build for wasm32, but line 1051-1055 makes RS256 the default service-account feature that the guest and the napi binding build without, so rsa (RUSTSEC-2023-0071) only ships on native targets where aws-lc-rs builds and is preferred. Use aws-lc-rs for RS256 on native, or restate the reason.

Source: rust-security · claude-opus-5-5


Generated by Claude Code

it runs on native Rust and in the WASI guest alike.
- **Decision:** the vendor transport is `VendorTransport { send(VendorRequest)
-> VendorResponse, sleep(Duration) }`, futures bounded by `MaybeSend`, the
shape of `ZeroKMSConnection`, plus `classify_status` (401/403

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor The shared classify_status default maps other 4xx to InvalidCiphertext, while AWS, Vault, GCP and Azure each override it to AccessDenied ("the CIP-5307 rule", lines 298, 486, 652, 870). Make AccessDenied the shared default so a new vendor, or an AWS 4xx with no code, does not report a refused request as damaged ciphertext.

Source: code-review · claude-opus-5-5


Generated by Claude Code

the alternatives and the reason.

## Stack structure

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor "Stack structure" still says one stack on main with design PR #1521 rebased onto origin/main; this PR is #1624 on next (line 1593), and the layer list still includes the CLI and CIP-5313. Rewrite it to the chain in the PR body or mark it superseded.

Source: code-review · claude-opus-5-5


Generated by Claude Code


We therefore use **derived data keys**. The keyset record holds a root key
wrapped by the vendor key. The vendor key source unwraps it once and caches
it for a TTL. Each data key is HKDF-SHA256(root key, salt = iv, info =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor The data-key formula here is unframed, unlabelled concatenation (info = keyset id ‖ vendor ‖ mode ‖ descriptor), but the pinned derivation (decision log lines 148-157) is frame["stack-kms-vendor/derived-data-key/v1", …], and the index key uses salt = none with its own label. A reimplementation from the ADR (e.g. the TypeScript follow-up) derives keys that cannot open Rust/Go values; state the framed, labelled formulas here.

Source: rust-security · claude-opus-5-5


Generated by Claude Code

wrapped by the vendor key. The vendor key source unwraps it once and caches
it for a TTL. Each data key is HKDF-SHA256(root key, salt = iv, info =
keyset id ‖ vendor ‖ mode ‖ descriptor), and the index key is derived from
the same root key under a separate label. Vendor calls are O(keysets), not

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor "Vendor calls are O(keysets)" holds only per TTL window: with WithRootKeyTTL(0) the log measures one unwrap per Encrypt and per Decrypt (lines 1213-1226). Say that a zero or short TTL costs one vendor call per operation, so an operator tightening revocation is not surprised by vendor throttling.

Source: code-review · claude-opus-5-5


Generated by Claude Code

code. Each vendor is a separate crate (`stack-kms-aws` and so on), so that no
user compiles all four.

The guest signs each vendor request itself (SigV4, the Google OAuth JWT, the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor The body still says the guest signs SigV4, the Google OAuth JWT, the Azure AD token and the Vault token; the 2026-11-06 amendment (line 45: "the guest still does no RSA") and the decision log (line 1405: "The guest signs SigV4 only") say otherwise, and neither amendment withdraws this sentence. Amend it to: the guest signs SigV4 only; the host supplies ready Google, Azure and Vault tokens.

Source: code-review · claude-opus-5-5


Generated by Claude Code

> 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor ADR-0005 does not state that "no key material crosses back to the host" — it covers the credential guest's sandbox and concedes the client key passes through host memory. Cite where the property is actually stated (languages/golang/encrypt/doc.go) or state it as this ADR's premise.

Source: code-review · claude-opus-5-5


Generated by Claude Code

_Avoid_: vendor KMS, third-party KMS, BYOK (that is ZeroKMS rooted in a
customer's KMS, not ZeroKMS replaced by it)

**Keyset record**:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor The Keyset record entry omits the record's optional name, which is unauthenticated and routes ahead of ZeroKMS under KeyRouter (decision log lines 125, 136, 171); "Name binding" (line 426) still says a name is a lookup ZeroKMS answers. Add the name to this entry and widen Name binding to cover names resolved locally from records.

Source: code-review · claude-opus-5-5


Generated by Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants