diff --git a/.changeset/auth-keyset-records.md b/.changeset/auth-keyset-records.md new file mode 100644 index 0000000000..2f8b9cebe7 --- /dev/null +++ b/.changeset/auth-keyset-records.md @@ -0,0 +1,13 @@ +--- +'@cipherstash/auth': minor +--- + +Add `keysetCreate` and `keysetRewrap` (Node only). They create and rewrap keyset records for the key vendor backends (AWS KMS, Google Cloud KMS, Azure Key Vault, HashiCorp Vault Transit and the static key backend), through the same Rust vendor clients that the Rust and Go SDKs use. ZeroKMS users do not need them. `stash keyset create` and `stash keyset rewrap` call them. + +- Each vendor's credentials come from its environment chain. +- `backend` takes the short names (`aws`, `gcp`, `azure`, `vault`, `static`) and the full names that the result gives (`aws-kms`, `gcp-kms`, `azure-key-vault`, `vault-transit`). +- `mode` is `derived`, the only key mode in this release. Another mode is refused with `INVALID_OPTIONS`. +- `keysetRewrap` wraps the record's root key under another vendor key of the same backend. It does not re-key stored values, and every stored value still opens. +- Both return a `Result`; a failure is a `KeysetFailure`. + +For Google Cloud KMS, set `GOOGLE_OAUTH_ACCESS_TOKEN` (for example from `gcloud auth print-access-token`) or run on Google Cloud. Service account key files are not read, so the binary has no RSA signing code. diff --git a/.changeset/stash-keyset-commands.md b/.changeset/stash-keyset-commands.md new file mode 100644 index 0000000000..4abdf713d1 --- /dev/null +++ b/.changeset/stash-keyset-commands.md @@ -0,0 +1,13 @@ +--- +'stash': minor +--- + +Add `stash keyset create` and `stash keyset rewrap`, for keysets held by a key vendor backend (AWS KMS, Google Cloud KMS, Azure Key Vault, HashiCorp Vault Transit, or the static key backend). ZeroKMS users never need them. + +- `stash keyset create --backend --vendor-key [--name ]` makes a keyset record and prints it as `CS_KEYSET_RECORD` (or `CS_KEYSET_RECORD_`). `--backend` also takes the full names that the output gives (`aws-kms`, `gcp-kms`, `azure-key-vault`, `vault-transit`). The record is not secret. Pass its value to `encrypt.WithKeysetRecords` (Go) or `KeysetRecord::parse` (Rust). +- `--mode` is optional. `derived` is the default and the only key mode in this release. +- `stash keyset rewrap [--vendor-key ]` wraps a record's root key again, after a key rotation in the vendor or to move to another vendor key of the same backend. The keyset id does not change. Stored data is not re-keyed, and every stored value still opens. +- Credentials come from each vendor's environment chain, never from a flag. A static key is named by its environment variable (`--key-env`); without one, a new key is generated and printed as `CS_STATIC_KEY`. +- `--out ` writes the block with mode 0600 (`--force` to overwrite), and `--json` emits one NDJSON event. + +The `stash-cli` skill documents both commands, and the `stash-zerokms` skill says that key vendor backends exist for the Rust crates and the Go SDK, and that TypeScript does not support them yet. diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index f5a3254ce5..a7512c8f24 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -67,6 +67,17 @@ on: # require-auth-npm-changeset.yml. - 'packages/stack-auth/Cargo.toml' - 'packages/stack-auth/src/**' + # The key vendor crates the auth binding compiles in (stash keyset). + - 'packages/stack-kms-vendor/Cargo.toml' + - 'packages/stack-kms-vendor/src/**' + - 'packages/stack-kms-aws/Cargo.toml' + - 'packages/stack-kms-aws/src/**' + - 'packages/stack-kms-vault/Cargo.toml' + - 'packages/stack-kms-vault/src/**' + - 'packages/stack-kms-gcp/Cargo.toml' + - 'packages/stack-kms-gcp/src/**' + - 'packages/stack-kms-azure/Cargo.toml' + - 'packages/stack-kms-azure/src/**' - 'languages/typescript/packages/auth/**' - 'languages/typescript/packages/stack-auth-wasm/**' # The Rust that produces every EQL payload these suites round-trip. @@ -175,6 +186,17 @@ on: # require-auth-npm-changeset.yml. - 'packages/stack-auth/Cargo.toml' - 'packages/stack-auth/src/**' + # The key vendor crates the auth binding compiles in (stash keyset). + - 'packages/stack-kms-vendor/Cargo.toml' + - 'packages/stack-kms-vendor/src/**' + - 'packages/stack-kms-aws/Cargo.toml' + - 'packages/stack-kms-aws/src/**' + - 'packages/stack-kms-vault/Cargo.toml' + - 'packages/stack-kms-vault/src/**' + - 'packages/stack-kms-gcp/Cargo.toml' + - 'packages/stack-kms-gcp/src/**' + - 'packages/stack-kms-azure/Cargo.toml' + - 'packages/stack-kms-azure/src/**' - 'languages/typescript/packages/auth/**' - 'languages/typescript/packages/stack-auth-wasm/**' # The Rust that produces every EQL payload these suites round-trip. diff --git a/.github/workflows/integration-protect-ffi.yml b/.github/workflows/integration-protect-ffi.yml index 909f5bca89..bb3c068bc9 100644 --- a/.github/workflows/integration-protect-ffi.yml +++ b/.github/workflows/integration-protect-ffi.yml @@ -106,6 +106,17 @@ on: # require-auth-npm-changeset.yml. - 'packages/stack-auth/Cargo.toml' - 'packages/stack-auth/src/**' + # The key vendor crates the auth binding compiles in (stash keyset). + - 'packages/stack-kms-vendor/Cargo.toml' + - 'packages/stack-kms-vendor/src/**' + - 'packages/stack-kms-aws/Cargo.toml' + - 'packages/stack-kms-aws/src/**' + - 'packages/stack-kms-vault/Cargo.toml' + - 'packages/stack-kms-vault/src/**' + - 'packages/stack-kms-gcp/Cargo.toml' + - 'packages/stack-kms-gcp/src/**' + - 'packages/stack-kms-azure/Cargo.toml' + - 'packages/stack-kms-azure/src/**' - 'languages/typescript/packages/auth/**' - 'languages/typescript/packages/stack-auth-wasm/**' - '.github/actions/require-cs-secrets/**' @@ -155,6 +166,17 @@ on: # require-auth-npm-changeset.yml. - 'packages/stack-auth/Cargo.toml' - 'packages/stack-auth/src/**' + # The key vendor crates the auth binding compiles in (stash keyset). + - 'packages/stack-kms-vendor/Cargo.toml' + - 'packages/stack-kms-vendor/src/**' + - 'packages/stack-kms-aws/Cargo.toml' + - 'packages/stack-kms-aws/src/**' + - 'packages/stack-kms-vault/Cargo.toml' + - 'packages/stack-kms-vault/src/**' + - 'packages/stack-kms-gcp/Cargo.toml' + - 'packages/stack-kms-gcp/src/**' + - 'packages/stack-kms-azure/Cargo.toml' + - 'packages/stack-kms-azure/src/**' - 'languages/typescript/packages/auth/**' - 'languages/typescript/packages/stack-auth-wasm/**' - '.github/actions/require-cs-secrets/**' diff --git a/.github/workflows/integration-supabase.yml b/.github/workflows/integration-supabase.yml index 90917bd8bb..dedeba0331 100644 --- a/.github/workflows/integration-supabase.yml +++ b/.github/workflows/integration-supabase.yml @@ -60,6 +60,17 @@ on: # require-auth-npm-changeset.yml. - 'packages/stack-auth/Cargo.toml' - 'packages/stack-auth/src/**' + # The key vendor crates the auth binding compiles in (stash keyset). + - 'packages/stack-kms-vendor/Cargo.toml' + - 'packages/stack-kms-vendor/src/**' + - 'packages/stack-kms-aws/Cargo.toml' + - 'packages/stack-kms-aws/src/**' + - 'packages/stack-kms-vault/Cargo.toml' + - 'packages/stack-kms-vault/src/**' + - 'packages/stack-kms-gcp/Cargo.toml' + - 'packages/stack-kms-gcp/src/**' + - 'packages/stack-kms-azure/Cargo.toml' + - 'packages/stack-kms-azure/src/**' - 'languages/typescript/packages/auth/**' - 'languages/typescript/packages/stack-auth-wasm/**' # The Rust that produces every EQL payload these suites round-trip. @@ -154,6 +165,17 @@ on: # require-auth-npm-changeset.yml. - 'packages/stack-auth/Cargo.toml' - 'packages/stack-auth/src/**' + # The key vendor crates the auth binding compiles in (stash keyset). + - 'packages/stack-kms-vendor/Cargo.toml' + - 'packages/stack-kms-vendor/src/**' + - 'packages/stack-kms-aws/Cargo.toml' + - 'packages/stack-kms-aws/src/**' + - 'packages/stack-kms-vault/Cargo.toml' + - 'packages/stack-kms-vault/src/**' + - 'packages/stack-kms-gcp/Cargo.toml' + - 'packages/stack-kms-gcp/src/**' + - 'packages/stack-kms-azure/Cargo.toml' + - 'packages/stack-kms-azure/src/**' - 'languages/typescript/packages/auth/**' - 'languages/typescript/packages/stack-auth-wasm/**' # The Rust that produces every EQL payload these suites round-trip. diff --git a/.github/workflows/require-auth-npm-changeset.yml b/.github/workflows/require-auth-npm-changeset.yml index d71644af04..3180e96617 100644 --- a/.github/workflows/require-auth-npm-changeset.yml +++ b/.github/workflows/require-auth-npm-changeset.yml @@ -6,6 +6,9 @@ name: "Require @cipherstash/auth changeset" # and the npm API does not move: the npm binary is built from that crate, and # nothing else would release it. # +# The key vendor crates are listed too: the binding compiles them in for +# `stash keyset` (CIP-5312), so a change to one changes the npm binary. +# # Changes that never ship (tests, examples, devDependencies) are filtered out # by `check-auth-npm-changeset.mjs --shipped`. # @@ -18,6 +21,16 @@ on: paths: - packages/stack-auth/Cargo.toml - packages/stack-auth/src/** + - packages/stack-kms-vendor/Cargo.toml + - packages/stack-kms-vendor/src/** + - packages/stack-kms-aws/Cargo.toml + - packages/stack-kms-aws/src/** + - packages/stack-kms-vault/Cargo.toml + - packages/stack-kms-vault/src/** + - packages/stack-kms-gcp/Cargo.toml + - packages/stack-kms-gcp/src/** + - packages/stack-kms-azure/Cargo.toml + - packages/stack-kms-azure/src/** - languages/typescript/packages/auth/** - languages/typescript/packages/stack-auth-wasm/** - scripts/check-auth-npm-changeset.mjs @@ -84,6 +97,16 @@ jobs: git diff --name-only --no-renames "${BASE_SHA}...HEAD" -- \ packages/stack-auth/Cargo.toml \ packages/stack-auth/src \ + packages/stack-kms-vendor/Cargo.toml \ + packages/stack-kms-vendor/src \ + packages/stack-kms-aws/Cargo.toml \ + packages/stack-kms-aws/src \ + packages/stack-kms-vault/Cargo.toml \ + packages/stack-kms-vault/src \ + packages/stack-kms-gcp/Cargo.toml \ + packages/stack-kms-gcp/src \ + packages/stack-kms-azure/Cargo.toml \ + packages/stack-kms-azure/src \ languages/typescript/packages/auth \ languages/typescript/packages/stack-auth-wasm )"; then diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7939d29e4a..17068b31eb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -98,11 +98,10 @@ jobs: - '!docs/**' # The Go module, and the Rust crates no binding these jobs build # depends on. The auth and profile bindings compile stack-auth and - # stack-profile, which stay in. + # stack-profile, and the auth binding compiles the key vendor + # crates for `stash keyset` and, through them, stack-kms, + # stack-encrypt and stack-encrypt-derive; all of those stay in. - '!languages/golang/**' - - '!packages/stack-encrypt/**' - - '!packages/stack-encrypt-derive/**' - - '!packages/stack-kms/**' - '!packages/stack-guest-abi/**' - '!packages/stack-auth/fuzz/**' # EQL's SQL sources, test suites, codegen and tasks. What the diff --git a/AGENTS.md b/AGENTS.md index be78df259f..57663a1af9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l links are provenance only. - `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io, one version group), `stack-kms` (published to crates.io from 0.1.0, its own version group, re-exported by `stack-encrypt` as `stack_encrypt::kms`), `stack-encrypt` and `stack-encrypt-derive` (published to crates.io from 0.1.0, one version group; `eql-bindings`' `stack-encrypt` feature depends on them from the registry), and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". - `packages/stack-kms-vendor`, `packages/stack-kms-aws`, `packages/stack-kms-vault`, `packages/stack-kms-gcp`, `packages/stack-kms-azure`: The key vendor backends (ADR-0013 and ADR-0014 in `packages/stack-encrypt/docs/adr/`). `stack-kms-vendor` is the vendor seam (`KeyVendorBackend`), keyset records, the derived data key mode, the static key backend and the `VendorTransport` HTTP seam (feature `http`: the native reqwest transport). Its `test-support` feature holds the test code every vendor crate shares: the contract suite (`contract`), `FakeVendor`, and the recorded-fixture harness (`replay`: fixture types, `Replay`, `Recording`); a vendor crate's `tests/common/mod.rs` holds only its own emulator, keys and constants. `stack-kms-aws` is the AWS KMS backend: a hand-written JSON client with SigV4 signing in-crate and the AWS credential providers; its LocalStack test runs in `tests-crates.yml`'s `localstack` job. `stack-kms-vault` is the HashiCorp Vault Transit backend: a hand-written Transit client with token, AppRole and Kubernetes auth; its `vault server -dev` test runs in `tests-crates.yml`'s `vault` job. `stack-kms-gcp` is the Google Cloud KMS backend: a hand-written Cloud KMS REST client with CRC32C checks, service account (RS256 JWT, the pure-Rust `rsa` crate, which has the open advisory RUSTSEC-2023-0071) and metadata server credentials; the service account login is the default feature `service-account`, and a build without it holds no RSA code; Google has no Cloud KMS emulator, so its live test runs only in the opt-in, credentialed `integration-gcp-kms.yml`, which is inert until the `STACK_KMS_GCP_LIVE_KEY` repository variable is set. `stack-kms-azure` is the Azure Key Vault and Managed HSM backend: a hand-written Key Vault REST client (`wrapkey`/`unwrapkey`, RSA-OAEP-256 or A256KW, the associated data bound by `stack_kms_vendor::bound` because Key Vault has none) with Entra ID client secret, workload identity and managed identity credentials, and no RSA code of its own; Microsoft has no Key Vault emulator, so its live test runs only in the opt-in, credentialed `integration-azure-key-vault.yml`, which is inert until the `STACK_KMS_AZURE_LIVE_KEY` repository variable is set. Both live workflows call one job, `_test-kms-live.yml`; each keeps its triggers, opt-in and fork-PR condition, and its variable and secret names. All five are root workspace members, publishable in the `stack-kms-vendor` version group, and staged in `release-plz.toml` (`release = false`) until their first version is published by hand. The `stack-kms-vendor` crate docs hold the capability matrix of every key backend (key lifetime, vendor calls, what the vendor sees and can refuse, credentials, rotation, known limits); change it with the code it describes. TypeScript (`@cipherstash/stack`) cannot use a key vendor backend yet. -- `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. +- `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate and the five key vendor crates included (the binding compiles them in for `keysetCreate` and `keysetRewrap`, which `stash keyset` calls), needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. - `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. Releases are cut by hand with `.github/workflows/release-golang.yml` (`gh workflow run release-golang.yml -f version=X.Y.Z[-pre] --ref `): because the guests are gitignored on main and Go fetches a version as the files committed at its tag, the workflow builds them, commits them on a release-only commit no branch points at, tags it `languages/golang/vX.Y.Z` and pushes the tag alone, then attaches the EQL v4 SQL bundle to the GitHub release. The ref must carry the EQL v4 bundle, or the build stops at "Build the EQL SQL bundles". `scripts/go-release-version.mjs` refuses, before anything is built, every version Go would refuse (non-canonical semver, `+build` metadata, a v2+ major on a module path with no `/vN` suffix, a version shaped like a pseudo-version), a final version dispatched from anywhere but `main`, and a final version while `languages/golang/go.mod` pins a `github.com/cipherstash/vitaminc/…` module at a pseudo-version (vitaminc's Go bindings have no tags, and a TEMPORARY pin on an unreleased commit must be gone before the module is tagged); a pre-release may come from a branch and may carry such a pin. The build job also refuses a version the Go proxy already lists in `@v/list`, which catches a tag deleted after the proxy fetched it; it never asks the proxy for the version itself before the tag exists, because the proxy would cache "not found" for up to 30 minutes and `verify` would fail after the push. The build checks the `Cargo.lock` of the EQL workspace and both guest workspaces with `--locked` first, since none of those builds passes it. It runs in a job with no write token and hands the guests to a `publish` job that holds `contents: write` and runs only git and gh. `publish` fetches the commit with plain unauthenticated git rather than `actions/checkout`, whose `token` input defaults to the job's writable token, and refuses a release commit whose tree is not the one the build tested. `publish` takes the paths it commits from the workflow's fixed `GUESTS` list, never from the artifact, and refuses a `guests.sha256` that is not exactly one SHA-256 line for each of those three guests: the build job runs third-party code after writing that manifest, so a path read from it could put any file on the tag (`scripts/__tests__/go-release-artifact.test.mjs`). A version is immutable once the Go proxy has fetched it, so a tag is never moved or deleted: if `publish` or `verify` fails after the tag is pushed, use "Re-run failed jobs" on the same run, which keeps a tag that run pushed and creates or completes the release; a new dispatch of the same version is refused. Never tag a main commit by hand: `go get` would fetch a module with no guests. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) diff --git a/Cargo.lock b/Cargo.lock index 2422905130..9a355712e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3487,18 +3487,27 @@ dependencies = [ name = "stack-auth-node" version = "0.35.0" dependencies = [ + "base64 0.22.1", "cts-common", "jsonwebtoken", + "miette", "mocktail", "napi", "napi-build", "napi-derive", "serde_json", "stack-auth", + "stack-kms", + "stack-kms-aws", + "stack-kms-azure", + "stack-kms-gcp", + "stack-kms-vault", + "stack-kms-vendor", "stack-profile", "tempfile", "tokio", "url", + "vitaminc 0.9.0", "vitaminc-protected 0.9.1", "zeroize", ] diff --git a/SECURITY.md b/SECURITY.md index 20b255b938..fb312f97c7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,7 +19,7 @@ This repository is the CipherStash Stack monorepo for JavaScript/TypeScript. It | `@cipherstash/wizard` | AI-powered encryption setup | | `@cipherstash/protect-ffi` | Native FFI bindings to the CipherStash Client SDK — the Rust core `@cipherstash/stack` encrypts and decrypts through | | `@cipherstash/protect-ffi-darwin-arm64`
`@cipherstash/protect-ffi-darwin-x64`
`@cipherstash/protect-ffi-linux-arm64-gnu`
`@cipherstash/protect-ffi-linux-x64-gnu`
`@cipherstash/protect-ffi-linux-x64-musl`
`@cipherstash/protect-ffi-win32-x64-msvc` | Prebuilt per-platform binaries for `@cipherstash/protect-ffi`. Installed as optional dependencies; one is selected at load time for the host platform | -| `@cipherstash/auth` | Authentication strategies for CipherStash (napi-rs native binding, with a WASM build for edge runtimes) — imported from `cipherstash/cipherstash-suite` with the `stack-auth` crate it wraps. | +| `@cipherstash/auth` | Authentication strategies for CipherStash (napi-rs native binding, with a WASM build for edge runtimes) — imported from `cipherstash/cipherstash-suite` with the `stack-auth` crate it wraps. It also carries `keysetCreate` and `keysetRewrap` for `stash keyset`, which compile in the key vendor crates listed below | | `@cipherstash/auth-darwin-arm64`
`@cipherstash/auth-darwin-x64`
`@cipherstash/auth-linux-arm64-gnu`
`@cipherstash/auth-linux-x64-gnu`
`@cipherstash/auth-linux-x64-musl`
`@cipherstash/auth-win32-x64-msvc` | Prebuilt per-platform binaries for `@cipherstash/auth`. Installed as optional peer dependencies; one is selected at load time for the host platform | | `@cipherstash/eql` | Encrypt Query Language — the PostgreSQL SQL bundle (`eql_v3` schema: domains, operators, index-term extractors) that stores and queries encrypted payloads, plus its generated TypeScript types. Applied by `stash eql install` and by the Prisma Next adapter's migrations. Released in lockstep with the `eql-bindings` Rust crate, which emits the payloads this SQL reads | diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index 5f14a11a72..e105cfe1a6 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -16,6 +16,21 @@ napi = { version = "2", features = ["async", "tokio_rt"] } napi-derive = "2" serde_json = "1" zeroize = { workspace = true } +# `stash keyset create` / `rewrap` (src/keyset.rs, CIP-5312): keyset records +# for the key vendor backends. These crates are compiled into this binary from +# the tree, by path, so their crates.io release state does not matter here. +# stack-kms-gcp has no default features: without `service-account` it has no +# `rsa` code (RUSTSEC-2023-0071), and the binding builds its own credentials +# from an access token or the metadata server. +stack-kms = { path = "../../../../packages/stack-kms", default-features = false } +stack-kms-vendor = { path = "../../../../packages/stack-kms-vendor", features = ["http"] } +stack-kms-aws = { path = "../../../../packages/stack-kms-aws" } +stack-kms-vault = { path = "../../../../packages/stack-kms-vault" } +stack-kms-gcp = { path = "../../../../packages/stack-kms-gcp", default-features = false } +stack-kms-azure = { path = "../../../../packages/stack-kms-azure" } +vitaminc = { workspace = true } +base64 = { workspace = true } +miette = { workspace = true } [dev-dependencies] # `stack-auth/test-utils` gives the Rust #[cfg(test)] unit tests the no-timeout diff --git a/languages/typescript/packages/auth/__tests__/keyset.test.ts b/languages/typescript/packages/auth/__tests__/keyset.test.ts new file mode 100644 index 0000000000..2f3c298bc4 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/keyset.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it } from 'vitest' + +// Runtime coverage for `keysetCreate` / `keysetRewrap` through index.js, on +// both Result arms. The static key backend needs no network, so these run +// with no vendor. The vendor paths are covered by the vendor crates' own +// fixtures; the Rust unit tests in src/keyset.rs cover the option checks. +const { keysetCreate, keysetRewrap } = + require('../index.js') as typeof import('../index') + +const KEY_A = 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=' +const KEY_B = 'ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=' +const VARS = ['CS_TEST_KEYSET_KEY_A', 'CS_TEST_KEYSET_KEY_B'] as const + +afterEach(() => { + for (const name of VARS) delete process.env[name] +}) + +describe('keysetCreate', () => { + it('creates a static keyset record with a generated key', async () => { + const result = await keysetCreate({ + backend: 'static', + vendorKey: 'key-a', + mode: 'derived', + name: 'users', + }) + if (result.failure) throw result.failure.error + expect(result.data.record).toMatch(/^stack-keyset:[A-Za-z0-9_-]+$/) + expect(result.data).toMatchObject({ + backend: 'static', + mode: 'derived', + name: 'users', + vendorKey: 'key-a', + }) + expect(result.data.keysetId).toMatch(/^[0-9a-f-]{36}$/) + expect( + Buffer.from(result.data.generatedStaticKey ?? '', 'base64'), + ).toHaveLength(32) + }) + + it('reads a static key from the named environment variable', async () => { + process.env.CS_TEST_KEYSET_KEY_A = KEY_A + const result = await keysetCreate({ + backend: 'static', + vendorKey: 'key-a', + mode: 'derived', + staticKeyEnv: 'CS_TEST_KEYSET_KEY_A', + }) + if (result.failure) throw result.failure.error + expect(result.data.generatedStaticKey).toBeUndefined() + expect(result.data.name).toBeUndefined() + }) + + it('returns a failure, not a throw, for an unknown key mode', async () => { + const result = await keysetCreate({ + backend: 'static', + vendorKey: 'key-a', + mode: 'other', + }) + expect(result.data).toBeUndefined() + expect(result.failure?.type).toBe('INVALID_OPTIONS') + expect(result.failure?.error.message).toContain('derived') + expect(result.failure?.error).toBeInstanceOf(Error) + }) + + it('returns INVALID_OPTIONS for an unknown backend', async () => { + const result = await keysetCreate({ + backend: 'zerokms', + vendorKey: 'k', + mode: 'derived', + }) + expect(result.failure?.type).toBe('INVALID_OPTIONS') + expect(result.failure?.error.message).toContain( + 'aws, gcp, azure, vault, static', + ) + }) +}) + +describe('keysetRewrap', () => { + it('moves a static record to another key, keeping its id and name', async () => { + process.env.CS_TEST_KEYSET_KEY_A = KEY_A + process.env.CS_TEST_KEYSET_KEY_B = KEY_B + const created = await keysetCreate({ + backend: 'static', + vendorKey: 'key-a', + mode: 'derived', + name: 'users', + staticKeyEnv: 'CS_TEST_KEYSET_KEY_A', + }) + if (created.failure) throw created.failure.error + + const rewrapped = await keysetRewrap({ + record: created.data.record, + vendorKey: 'key-b', + staticKeyEnv: 'CS_TEST_KEYSET_KEY_A', + newStaticKeyEnv: 'CS_TEST_KEYSET_KEY_B', + }) + if (rewrapped.failure) throw rewrapped.failure.error + expect(rewrapped.data).toMatchObject({ + keysetId: created.data.keysetId, + name: 'users', + vendorKey: 'key-b', + }) + + // The old key no longer opens it. + const wrong = await keysetRewrap({ + record: rewrapped.data.record, + staticKeyEnv: 'CS_TEST_KEYSET_KEY_A', + }) + expect(wrong.failure?.type).toBe('VENDOR_ERROR') + expect(wrong.failure?.kind).toBe('InvalidCiphertext') + }) + + it('returns INVALID_RECORD for text that is not a record', async () => { + const result = await keysetRewrap({ record: 'nope' }) + expect(result.failure?.type).toBe('INVALID_RECORD') + expect(result.failure?.help).toContain('stash keyset create') + }) +}) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index cd36baf3b0..07cd211b50 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -20,9 +20,23 @@ */ import type { Result } from "@byteslice/result"; -import type { TokenResult, AuthResult, AutoStrategyOptions } from "./native"; +import type { + TokenResult, + AuthResult, + AutoStrategyOptions, + KeysetCreateOptions, + KeysetRewrapOptions, + KeysetResult, +} from "./native"; -export type { TokenResult, AuthResult, AutoStrategyOptions }; +export type { + TokenResult, + AuthResult, + AutoStrategyOptions, + KeysetCreateOptions, + KeysetRewrapOptions, + KeysetResult, +}; /** Fields present on every `AuthFailure`. */ interface FailureBase { @@ -183,6 +197,54 @@ export declare function beginDeviceCodeFlow( /** Provision a device client in ZeroKMS after login. */ export declare function bindClientDevice(): Promise>; +/** + * A failure of {@link keysetCreate} or {@link keysetRewrap}. Discriminated by + * `type`. No message carries key material or a credential. + * + * - `INVALID_OPTIONS`: an option is missing or not accepted. + * - `STATIC_KEY`: a static key could not be read from its variable. + * - `VENDOR_CONFIG`: the vendor client could not be configured from the + * environment (for example no region, address or credential). + * - `INVALID_RECORD`: the record could not be parsed or made (`diagnostic` + * names the `stack_kms_vendor::*` code). + * - `VENDOR_ERROR`: the vendor call failed; `kind` is `NotFound`, + * `AccessDenied`, `Throttled`, `Unavailable` or `InvalidCiphertext`. + */ +export type KeysetFailure = FailureBase & { + type: + | "INVALID_OPTIONS" + | "STATIC_KEY" + | "VENDOR_CONFIG" + | "INVALID_RECORD" + | "VENDOR_ERROR"; + /** The key backend, as the SDKs name it (`aws-kms`, …), when known. */ + vendor?: string; + /** The vendor failure kind, for `VENDOR_ERROR`. */ + kind?: string; + /** The `stack_kms_vendor::*` diagnostic code, for `INVALID_RECORD`. */ + diagnostic?: string; +}; + +/** + * Create a keyset in a key vendor backend (AWS KMS, Google Cloud KMS, Azure + * Key Vault, Vault Transit or the static key backend) and return its keyset + * record. ZeroKMS users never need this. The vendor's credentials come from + * its environment chain, as in the Rust and Go SDKs. Node only. + */ +export declare function keysetCreate( + options: KeysetCreateOptions, +): Promise>; + +/** + * Wrap a keyset record's key under a vendor key again, for example after a + * key rotation in the vendor. The keyset id, name and mode stay the same. + * Stored data is not re-keyed. A record may move to any key of its backend, + * and every stored value still opens. Node only. + */ +export declare function keysetRewrap( + options: KeysetRewrapOptions, +): Promise>; + /** * Deprecated alias for {@link DeviceSessionStrategy}, exported at runtime as * `module.exports.OAuthStrategy = DeviceSessionStrategy`. diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index a5f4fe8ff5..6181689bc1 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -242,6 +242,10 @@ module.exports = { OAuthStrategy: DeviceSessionStrategy, beginDeviceCodeFlow: wrapAsync(native.beginDeviceCodeFlow), bindClientDevice: wrapAsync(native.bindClientDevice), + // Keyset records for the key vendor backends (`stash keyset`). Their + // failures use the same sentinel, with `KeysetFailure` codes. + keysetCreate: wrapAsync(native.keysetCreate), + keysetRewrap: wrapAsync(native.keysetRewrap), }; if (native.beginDeviceCodeFlowWithBaseUrl) { diff --git a/languages/typescript/packages/auth/native.d.ts b/languages/typescript/packages/auth/native.d.ts index d38d002410..b6d71b20b4 100644 --- a/languages/typescript/packages/auth/native.d.ts +++ b/languages/typescript/packages/auth/native.d.ts @@ -3,6 +3,93 @@ /* auto-generated by NAPI-RS */ +/** The options of [`keyset_create`]. */ +export interface KeysetCreateOptions { + /** + * The key backend: `aws`, `gcp`, `azure`, `vault` or `static`, or the + * full name that [`KeysetResult::backend`] gives (`aws-kms`, + * `gcp-kms`, `azure-key-vault`, `vault-transit`). + */ + backend: string + /** + * The vendor key that wraps the keyset's key: an AWS KMS key ARN, a + * Cloud KMS crypto key name, an Azure Key Vault key identifier, a Vault + * Transit key name, or the reference of a static key. + */ + vendorKey: string + /** The key mode: `derived`, the only key mode in this release. */ + mode: string + /** An optional local name for the keyset. */ + name?: string + /** + * Static key backend only: the name of the environment variable that + * holds the static key (32 bytes, as base64, base64url or hex). When it + * is not given, a new static key is generated and returned. + */ + staticKeyEnv?: string +} +/** The options of [`keyset_rewrap`]. */ +export interface KeysetRewrapOptions { + /** The keyset record text (`stack-keyset:…`). */ + record: string + /** + * The vendor key to wrap under. When it is not given, the record's own + * vendor key is used, which moves the record to that key's current + * version. + */ + vendorKey?: string + /** + * Static key backend only: the environment variable that holds the + * static key the record names now. + */ + staticKeyEnv?: string + /** + * Static key backend only: the environment variable that holds the + * static key of `vendor_key`, when that is another reference. When it is + * not given, a new static key is generated and returned. + */ + newStaticKeyEnv?: string +} +/** A keyset record that [`keyset_create`] or [`keyset_rewrap`] made. */ +export interface KeysetResult { + /** The keyset record text (`stack-keyset:…`). It is not secret. */ + record: string + /** The keyset id (a UUID). */ + keysetId: string + /** The keyset name, if it has one. */ + name?: string + /** + * The key backend, as the SDKs name it (`aws-kms`, `gcp-kms`, + * `azure-key-vault`, `vault-transit` or `static`). + */ + backend: string + /** The key mode: `derived`. */ + mode: string + /** The vendor key that wraps the keyset's key. */ + vendorKey: string + /** + * Static key backend only: a static key this call generated, as padded + * standard base64. **This is a secret.** It is the only copy. + */ + generatedStaticKey?: string +} +/** + * Create a keyset in a key vendor backend and return its keyset record. + * + * One vendor call. The vendor's credentials come from its environment + * chain, as in the SDKs. + */ +export declare function keysetCreate(options: KeysetCreateOptions): Promise +/** + * Wrap a keyset record's key under a vendor key again (after a rotation in + * the vendor, or to move it to another vendor key of the same backend). The + * keyset id, name and mode stay the same. Stored data is not re-keyed: + * every data key comes from the keyset's root key, so every stored value + * still opens after a move to any key of the same backend. + * + * Two vendor calls: an unwrap and a wrap. + */ +export declare function keysetRewrap(options: KeysetRewrapOptions): Promise /** * The result of a successful `getToken()` call. * diff --git a/languages/typescript/packages/auth/src/keyset.rs b/languages/typescript/packages/auth/src/keyset.rs new file mode 100644 index 0000000000..943adb7309 --- /dev/null +++ b/languages/typescript/packages/auth/src/keyset.rs @@ -0,0 +1,987 @@ +//! Keyset records for the key vendor backends: the native half of +//! `stash keyset create` and `stash keyset rewrap` (CIP-5312). +//! +//! A ZeroKMS user never needs these functions. They exist for a keyset held +//! by a key vendor backend (AWS KMS, Google Cloud KMS, Azure Key Vault, +//! HashiCorp Vault Transit, or the static key backend), which the Rust and Go +//! SDKs load from a keyset record. +//! +//! Each vendor client is the Rust one, configured from the environment the +//! way the SDKs configure it (`AwsKms::from_env` and its peers), so the +//! request signing code exists once, in Rust. No credential crosses this +//! boundary as an argument. The one secret that can come back is a static +//! key that this module generated, because the caller must store it. + +use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD}; +use base64::Engine as _; +use miette::Diagnostic; +use napi::Status; +use napi_derive::napi; +use stack_kms::{KeyMode, KeyVendor}; +use stack_kms_vendor::{ + HttpVendorTransport, HttpVendorTransportOptions, KeyBytes, KeyVendorBackend, KeysetRecord, + RecordError, StaticKeyVendor, +}; +use std::future::Future; +use std::pin::Pin; +use vitaminc::protected::{Controlled, Protected}; +use vitaminc::random::{Generatable, SafeRand}; +use vitaminc_protected::OpaqueDebug; +use zeroize::Zeroizing; + +use crate::FAILURE_SENTINEL; + +/// The length of a static key, in bytes. +const STATIC_KEY_LEN: usize = 32; + +// --------------------------------------------------------------------------- +// The napi surface +// --------------------------------------------------------------------------- + +/// The options of [`keyset_create`]. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct KeysetCreateOptions { + /// The key backend: `aws`, `gcp`, `azure`, `vault` or `static`, or the + /// full name that [`KeysetResult::backend`] gives (`aws-kms`, + /// `gcp-kms`, `azure-key-vault`, `vault-transit`). + pub backend: String, + /// The vendor key that wraps the keyset's key: an AWS KMS key ARN, a + /// Cloud KMS crypto key name, an Azure Key Vault key identifier, a Vault + /// Transit key name, or the reference of a static key. + pub vendor_key: String, + /// The key mode: `derived`, the only key mode in this release. + pub mode: String, + /// An optional local name for the keyset. + pub name: Option, + /// Static key backend only: the name of the environment variable that + /// holds the static key (32 bytes, as base64, base64url or hex). When it + /// is not given, a new static key is generated and returned. + pub static_key_env: Option, +} + +/// The options of [`keyset_rewrap`]. +#[napi(object)] +#[derive(Debug, Clone)] +pub struct KeysetRewrapOptions { + /// The keyset record text (`stack-keyset:…`). + pub record: String, + /// The vendor key to wrap under. When it is not given, the record's own + /// vendor key is used, which moves the record to that key's current + /// version. + pub vendor_key: Option, + /// Static key backend only: the environment variable that holds the + /// static key the record names now. + pub static_key_env: Option, + /// Static key backend only: the environment variable that holds the + /// static key of `vendor_key`, when that is another reference. When it is + /// not given, a new static key is generated and returned. + pub new_static_key_env: Option, +} + +/// A keyset record that [`keyset_create`] or [`keyset_rewrap`] made. +#[derive(OpaqueDebug)] +#[napi(object)] +pub struct KeysetResult { + /// The keyset record text (`stack-keyset:…`). It is not secret. + pub record: String, + /// The keyset id (a UUID). + pub keyset_id: String, + /// The keyset name, if it has one. + pub name: Option, + /// The key backend, as the SDKs name it (`aws-kms`, `gcp-kms`, + /// `azure-key-vault`, `vault-transit` or `static`). + pub backend: String, + /// The key mode: `derived`. + pub mode: String, + /// The vendor key that wraps the keyset's key. + pub vendor_key: String, + /// Static key backend only: a static key this call generated, as padded + /// standard base64. **This is a secret.** It is the only copy. + pub generated_static_key: Option, +} + +/// Create a keyset in a key vendor backend and return its keyset record. +/// +/// One vendor call. The vendor's credentials come from its environment +/// chain, as in the SDKs. +#[napi] +pub async fn keyset_create(options: KeysetCreateOptions) -> napi::Result { + create(options).await.map_err(KeysetError::into_napi) +} + +/// Wrap a keyset record's key under a vendor key again (after a rotation in +/// the vendor, or to move it to another vendor key of the same backend). The +/// keyset id, name and mode stay the same. Stored data is not re-keyed: +/// every data key comes from the keyset's root key, so every stored value +/// still opens after a move to any key of the same backend. +/// +/// Two vendor calls: an unwrap and a wrap. +#[napi] +pub async fn keyset_rewrap(options: KeysetRewrapOptions) -> napi::Result { + rewrap(options).await.map_err(KeysetError::into_napi) +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// A failure of a keyset operation. No message carries key material or a +/// credential: the vendor crates build their messages from fixed text, the +/// HTTP status and the vendor's error code (see CIP-5311 in the decision +/// log), and this module never puts a key in one. +#[derive(Debug)] +pub(crate) enum KeysetError { + /// An option is missing or has a value this module does not accept. + Options(String), + /// A static key could not be read or generated. + StaticKey(String), + /// The vendor client could not be configured from the environment. + VendorConfig { vendor: KeyVendor, message: String }, + /// The record could not be parsed, created or rewrapped. + Record(RecordError), +} + +impl KeysetError { + /// The stable code the TypeScript side narrows on. + fn code(&self) -> &'static str { + match self { + Self::Options(_) => "INVALID_OPTIONS", + Self::StaticKey(_) => "STATIC_KEY", + Self::VendorConfig { .. } => "VENDOR_CONFIG", + Self::Record(RecordError::Kms(_)) => "VENDOR_ERROR", + Self::Record(_) => "INVALID_RECORD", + } + } + + /// The failure envelope: `{ type, message, help?, vendor?, kind?, diagnostic? }`. + pub(crate) fn envelope(&self) -> serde_json::Value { + let mut fields = serde_json::Map::new(); + let _ = fields.insert("type".into(), self.code().into()); + let _ = fields.insert("message".into(), self.message().into()); + match self { + Self::VendorConfig { vendor, .. } => { + let _ = fields.insert("vendor".into(), vendor.name().into()); + } + Self::Record(error) => { + if let Some(code) = error.code() { + let _ = fields.insert("diagnostic".into(), code.to_string().into()); + } + if let Some(help) = error.help() { + let _ = fields.insert("help".into(), help.to_string().into()); + } + if let RecordError::Kms(stack_kms::Error::Backend { backend, kind, .. }) = error { + let _ = fields.insert("vendor".into(), backend.name().into()); + let _ = fields.insert("kind".into(), kind.as_str().into()); + } + } + Self::Options(_) | Self::StaticKey(_) => {} + } + serde_json::Value::Object(fields) + } + + fn message(&self) -> String { + match self { + Self::Options(message) | Self::StaticKey(message) => message.clone(), + Self::VendorConfig { vendor, message } => { + format!("Could not configure the {vendor} client from the environment: {message}") + } + Self::Record(error) => error_chain(error), + } + } + + fn into_napi(self) -> napi::Error { + napi::Error::new( + Status::GenericFailure, + format!("{FAILURE_SENTINEL}{}", self.envelope()), + ) + } +} + +impl From for KeysetError { + fn from(error: RecordError) -> Self { + Self::Record(error) + } +} + +/// An error and its sources, joined with `: `, without repeats (a +/// `#[error(transparent)]` wrapper prints its source's text). +fn error_chain(error: &(dyn std::error::Error + 'static)) -> String { + let mut text = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + let next = cause.to_string(); + if !text.ends_with(&next) { + text.push_str(": "); + text.push_str(&next); + } + source = cause.source(); + } + text +} + +fn vendor_config(vendor: KeyVendor, error: impl std::error::Error + 'static) -> KeysetError { + KeysetError::VendorConfig { + vendor, + message: error_chain(&error), + } +} + +// --------------------------------------------------------------------------- +// Parsing the options +// --------------------------------------------------------------------------- + +/// The backends `stash keyset` can use, in the order the help text lists +/// them, each with its short name (the `--backend` value). The output +/// names a backend with [`KeyVendor::name`] (`aws-kms`, …), and the input +/// takes that name too, so a value the output gives is a valid input. +/// This table is the only list of the backends in this module: adding a +/// vendor is one row here. +pub(crate) const BACKENDS: [Backend; 5] = [ + Backend { + short: "aws", + vendor: KeyVendor::Aws, + run: Some(run_aws), + }, + Backend { + short: "gcp", + vendor: KeyVendor::Gcp, + run: Some(run_gcp), + }, + Backend { + short: "azure", + vendor: KeyVendor::Azure, + run: Some(run_azure), + }, + Backend { + short: "vault", + vendor: KeyVendor::Vault, + run: Some(run_vault), + }, + Backend { + short: "static", + vendor: KeyVendor::Static, + // The static key is not in the environment: `create` and `rewrap` + // read or generate it, and build the backend themselves. + run: None, + }, +]; + +/// One row of [`BACKENDS`]. +pub(crate) struct Backend { + /// The short `--backend` name. + pub(crate) short: &'static str, + /// The vendor. + pub(crate) vendor: KeyVendor, + /// Build the vendor's client from the environment and run an operation + /// on it. `None` for the static key backend. + run: Option, +} + +/// A [`Backend::run`]: one vendor call (or two, for a rewrap) through a +/// client built from the environment. +type RunVendor = fn(Operation) -> Pin + Send>>; + +/// What a [`RunVendor`] returns. +type VendorResult = Result; + +/// The backend that `text` names: a short name of [`BACKENDS`] or the +/// [`KeyVendor::name`] of one of its vendors. +fn parse_backend(text: &str) -> Result { + BACKENDS + .iter() + .find(|backend| backend.short == text || backend.vendor.name() == text) + .map(|backend| backend.vendor) + .ok_or_else(|| { + KeysetError::Options(format!( + "Unknown key backend {text:?}. {}", + backend_choices() + )) + }) +} + +/// The accepted backend names, for an error message, from [`BACKENDS`]. +fn backend_choices() -> String { + let short: Vec<&str> = BACKENDS.iter().map(|backend| backend.short).collect(); + let full: Vec<&str> = BACKENDS + .iter() + .map(|backend| backend.vendor.name()) + .filter(|name| !short.contains(name)) + .collect(); + format!( + "Use one of: {}. The full names {} also work.", + short.join(", "), + full.join(", ") + ) +} + +fn parse_mode(text: &str) -> Result { + KeyMode::ALL + .into_iter() + .find(|mode| mode.name() == text) + .ok_or_else(|| KeysetError::Options(format!("Unknown key mode {text:?}. Use derived."))) +} + +fn non_empty(field: &str, value: Option) -> Result, KeysetError> { + match value { + Some(value) if value.is_empty() => Err(KeysetError::Options(format!( + "The {field} must not be empty." + ))), + other => Ok(other), + } +} + +/// Refuse a static key option on a vendor that has no static key. +fn refuse_static_options( + vendor: KeyVendor, + options: &[&Option], +) -> Result<(), KeysetError> { + if vendor != KeyVendor::Static && options.iter().any(|option| option.is_some()) { + return Err(KeysetError::Options(format!( + "A static key option is only for the static key backend, not {vendor}." + ))); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Static keys +// --------------------------------------------------------------------------- + +/// Read a static key from the environment variable `var`. The variable holds +/// 32 bytes as base64 (standard or URL-safe, padded or not) or as 64 +/// hexadecimal characters. +pub(crate) fn read_static_key(var: &str) -> Result { + let text = Zeroizing::new(std::env::var(var).map_err(|_| { + KeysetError::StaticKey(format!( + "The environment variable {var} is not set (it must hold the static key)." + )) + })?); + decode_static_key(text.trim()).ok_or_else(|| { + KeysetError::StaticKey(format!( + "The environment variable {var} does not hold a static key: \ + use 32 bytes as base64 or as 64 hexadecimal characters." + )) + }) +} + +/// Decode a static key, or `None` when `text` is not 32 bytes in one of the +/// accepted encodings. Never returns the text in an error. +pub(crate) fn decode_static_key(text: &str) -> Option { + let bytes: Zeroizing> = + if text.len() == 2 * STATIC_KEY_LEN && text.bytes().all(|byte| byte.is_ascii_hexdigit()) { + Zeroizing::new( + (0..STATIC_KEY_LEN) + .map(|i| u8::from_str_radix(&text[2 * i..2 * i + 2], 16)) + .collect::, _>>() + .ok()?, + ) + } else { + Zeroizing::new( + [STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD] + .iter() + .find_map(|engine| engine.decode(text).ok())?, + ) + }; + let array: [u8; STATIC_KEY_LEN] = bytes.as_slice().try_into().ok()?; + Some(Protected::new(array)) +} + +/// A new static key from the CSPRNG, and its padded standard base64. +fn generate_static_key() -> Result<(KeyBytes, String), KeysetError> { + let failed = |_| KeysetError::StaticKey("Could not generate a static key.".into()); + let mut rng = SafeRand::from_entropy().map_err(failed)?; + let key = KeyBytes::random(&mut rng).map_err(failed)?; + let text = STANDARD.encode(key.risky_ref()); + Ok((key, text)) +} + +// --------------------------------------------------------------------------- +// The operations +// --------------------------------------------------------------------------- + +/// What to do with the backend, once it is built. +enum Operation { + Create { + vendor_key: String, + mode: KeyMode, + name: Option, + }, + Rewrap { + record: KeysetRecord, + vendor_key: String, + }, +} + +impl Operation { + async fn run(self, backend: &B) -> Result { + Ok(match self { + Self::Create { + vendor_key, + mode, + name, + } => KeysetRecord::create(backend, vendor_key, mode, name).await?, + Self::Rewrap { record, vendor_key } => record.rewrap(backend, vendor_key).await?, + }) + } +} + +pub(crate) async fn create(options: KeysetCreateOptions) -> Result { + let vendor = parse_backend(&options.backend)?; + let mode = parse_mode(&options.mode)?; + let name = non_empty("keyset name", options.name)?; + let static_key_env = non_empty("static key variable", options.static_key_env)?; + refuse_static_options(vendor, &[&static_key_env])?; + if options.vendor_key.is_empty() { + return Err(KeysetError::Options( + "The vendor key must not be empty.".into(), + )); + } + let operation = Operation::Create { + vendor_key: options.vendor_key, + mode, + name, + }; + let (record, generated) = if vendor == KeyVendor::Static { + let key_ref = options_vendor_key_of(&operation); + let (key, generated) = match &static_key_env { + Some(var) => (read_static_key(var)?, None), + None => { + let (key, text) = generate_static_key()?; + (key, Some(text)) + } + }; + let backend = StaticKeyVendor::new(key_ref, key); + (operation.run(&backend).await?, generated) + } else { + (run_vendor(vendor, operation).await?, None) + }; + Ok(result(&record, generated)) +} + +pub(crate) async fn rewrap(options: KeysetRewrapOptions) -> Result { + let record = KeysetRecord::parse(options.record.trim())?; + let vendor = record.vendor(); + let vendor_key = + non_empty("vendor key", options.vendor_key)?.unwrap_or_else(|| record.key_ref().to_owned()); + let static_key_env = non_empty("static key variable", options.static_key_env)?; + let new_static_key_env = non_empty("new static key variable", options.new_static_key_env)?; + refuse_static_options(vendor, &[&static_key_env, &new_static_key_env])?; + + if vendor != KeyVendor::Static { + let record = run_vendor(vendor, Operation::Rewrap { record, vendor_key }).await?; + return Ok(result(&record, None)); + } + + let var = static_key_env.ok_or_else(|| { + KeysetError::Options( + "The record is in the static key backend: name the variable that holds its \ + static key with --key-env." + .into(), + ) + })?; + let current = read_static_key(&var)?; + let mut backend = StaticKeyVendor::new(record.key_ref(), current); + let mut generated = None; + if vendor_key != record.key_ref() { + let key = match &new_static_key_env { + Some(var) => read_static_key(var)?, + None => { + let (key, text) = generate_static_key()?; + generated = Some(text); + key + } + }; + backend = backend.with_key(vendor_key.clone(), key); + } else if new_static_key_env.is_some() { + return Err(KeysetError::Options( + "--new-key-env needs --vendor-key with another static key reference.".into(), + )); + } + let record = Operation::Rewrap { record, vendor_key } + .run(&backend) + .await?; + Ok(result(&record, generated)) +} + +fn options_vendor_key_of(operation: &Operation) -> String { + match operation { + Operation::Create { vendor_key, .. } | Operation::Rewrap { vendor_key, .. } => { + vendor_key.clone() + } + } +} + +fn result(record: &KeysetRecord, generated_static_key: Option) -> KeysetResult { + KeysetResult { + record: record.to_string(), + keyset_id: record.id().to_string(), + name: record.name().map(str::to_owned), + backend: record.vendor().name().to_owned(), + mode: record.mode().name().to_owned(), + vendor_key: record.key_ref().to_owned(), + generated_static_key, + } +} + +/// Build the vendor's client from the environment and run `operation` on it. +/// The vendor's row of [`BACKENDS`] says how. +async fn run_vendor(vendor: KeyVendor, operation: Operation) -> VendorResult { + // The callers handle the static key backend before this point, and + // `KeyVendor` is non-exhaustive. + let run = BACKENDS + .iter() + .find(|backend| backend.vendor == vendor) + .and_then(|backend| backend.run) + .ok_or_else(|| { + KeysetError::Options(format!( + "stash keyset cannot reach the {vendor} key backend." + )) + })?; + run(operation).await +} + +fn run_aws(operation: Operation) -> Pin + Send>> { + Box::pin(async move { + let kms = stack_kms_aws::DefaultAwsKms::from_env() + .map_err(|error| vendor_config(KeyVendor::Aws, error))?; + operation.run(&kms).await + }) +} + +fn run_vault(operation: Operation) -> Pin + Send>> { + Box::pin(async move { + let vault = stack_kms_vault::DefaultVaultTransit::from_env() + .map_err(|error| vendor_config(KeyVendor::Vault, error))?; + operation.run(&vault).await + }) +} + +fn run_azure(operation: Operation) -> Pin + Send>> { + Box::pin(async move { + let azure = stack_kms_azure::DefaultAzureKeyVault::from_env() + .map_err(|error| vendor_config(KeyVendor::Azure, error))?; + operation.run(&azure).await + }) +} + +fn run_gcp(operation: Operation) -> Pin + Send>> { + Box::pin(gcp(operation)) +} + +/// Google Cloud KMS, with the credentials this binding supports. +/// +/// The binding builds `stack-kms-gcp` without its `service-account` feature, +/// so it has no RSA code (RUSTSEC-2023-0071). It therefore takes a token from +/// `GOOGLE_OAUTH_ACCESS_TOKEN` (for example from `gcloud auth +/// print-access-token`), else the metadata server on Google Cloud. A service +/// account key file (`GOOGLE_APPLICATION_CREDENTIALS`) is refused with that +/// remedy, so it is never ignored. +async fn gcp(operation: Operation) -> VendorResult { + use stack_kms_gcp::{ + AccessToken, CachedToken, GcpKms, GcpKmsConfig, MetadataServerLogin, ACCESS_TOKEN_VARIABLE, + }; + + let vendor = KeyVendor::Gcp; + let env = stack_kms_gcp::process_env(); + let transport = HttpVendorTransport::new().map_err(|error| vendor_config(vendor, error))?; + if let Some(token) = env(ACCESS_TOKEN_VARIABLE).map(AccessToken::new) { + let kms = GcpKms::new(GcpKmsConfig::new(), transport, token); + return operation.run(&kms).await; + } + if env("GOOGLE_APPLICATION_CREDENTIALS").is_some() { + return Err(KeysetError::VendorConfig { + vendor, + message: "stash keyset does not read service account key files \ + (GOOGLE_APPLICATION_CREDENTIALS). Set GOOGLE_OAUTH_ACCESS_TOKEN, \ + for example to the output of `gcloud auth print-access-token`, \ + or run on Google Cloud with an attached service account." + .into(), + }); + } + // A 1 second connect limit, as in `GcpKms::from_env`, so that off Google + // Cloud the metadata server probe fails fast. + let credentials_transport = HttpVendorTransport::with_options(HttpVendorTransportOptions { + timeout: std::time::Duration::from_secs(10), + connect_timeout: Some(std::time::Duration::from_secs(1)), + }) + .map_err(|error| vendor_config(vendor, error))?; + let login = MetadataServerLogin::from_env_lookup(credentials_transport, &env) + .map_err(|error| vendor_config(vendor, error))?; + let kms = GcpKms::new(GcpKmsConfig::new(), transport, CachedToken::new(login)); + operation.run(&kms).await +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + const KEY_A: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="; + const KEY_A_HEX: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + const KEY_B: &str = "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8="; + + fn create_options(backend: &str, mode: &str) -> KeysetCreateOptions { + KeysetCreateOptions { + backend: backend.into(), + vendor_key: "key-a".into(), + mode: mode.into(), + name: Some("users".into()), + static_key_env: None, + } + } + + /// Each test uses its own variable names, because tests run in parallel + /// threads of one process. + fn set(var: &str, value: &str) { + std::env::set_var(var, value); + } + + fn envelope(error: KeysetError) -> serde_json::Value { + error.envelope() + } + + #[test] + fn decodes_every_accepted_static_key_encoding() { + let expected: Vec = (0u8..32).collect(); + for text in [ + KEY_A, + KEY_A.trim_end_matches('='), + KEY_A_HEX, + &KEY_A_HEX.to_uppercase(), + // base64url of the same bytes (no `+` or `/` in it, so it is + // also the standard form; check a key with them below). + ] { + let key = decode_static_key(text).unwrap(); + assert_eq!(key.risky_ref().as_slice(), expected.as_slice(), "{text}"); + } + let all_ones = [0xfbu8; 32]; + let standard = STANDARD.encode(all_ones); + let url = URL_SAFE_NO_PAD.encode(all_ones); + assert!(standard.contains('+') || standard.contains('/')); + for text in [standard.as_str(), url.as_str()] { + assert_eq!(decode_static_key(text).unwrap().risky_ref(), &all_ones); + } + } + + #[test] + fn refuses_a_static_key_that_is_not_32_bytes() { + for text in [ + "", + "abcd", + &STANDARD.encode([0u8; 31]), + &STANDARD.encode([0u8; 33]), + &"0".repeat(63), + "not base64 at all!", + ] { + assert!(decode_static_key(text).is_none(), "{text:?}"); + } + } + + #[test] + fn a_static_key_error_does_not_name_the_value() { + set("CIP5312_TEST_BAD_KEY", "secret-looking-value"); + let error = read_static_key("CIP5312_TEST_BAD_KEY").err().unwrap(); + let text = envelope(error).to_string(); + assert!(text.contains("CIP5312_TEST_BAD_KEY")); + assert!(!text.contains("secret-looking-value")); + assert!(text.contains("\"type\":\"STATIC_KEY\"")); + } + + #[test] + fn a_missing_static_key_variable_is_refused() { + let error = read_static_key("CIP5312_TEST_UNSET_KEY").err().unwrap(); + assert!(envelope(error).to_string().contains("is not set")); + } + + #[tokio::test] + async fn creates_a_static_record_with_a_generated_key() { + let created = create(create_options("static", "derived")).await.unwrap(); + assert!(created.record.starts_with("stack-keyset:")); + assert_eq!(created.backend, "static"); + assert_eq!(created.mode, "derived"); + assert_eq!(created.name.as_deref(), Some("users")); + assert_eq!(created.vendor_key, "key-a"); + let record = KeysetRecord::parse(&created.record).unwrap(); + assert_eq!(record.id().to_string(), created.keyset_id); + + // The generated key opens the record: rewrap under it. + let key = created.generated_static_key.unwrap(); + assert!(decode_static_key(&key).is_some()); + set("CIP5312_TEST_GENERATED", &key); + let rewrapped = rewrap(KeysetRewrapOptions { + record: created.record.clone(), + vendor_key: None, + static_key_env: Some("CIP5312_TEST_GENERATED".into()), + new_static_key_env: None, + }) + .await + .unwrap(); + assert_eq!(rewrapped.keyset_id, created.keyset_id); + assert!(rewrapped.generated_static_key.is_none()); + // A new nonce, so the record text changes. + assert_ne!(rewrapped.record, created.record); + } + + #[tokio::test] + async fn creates_a_static_record_with_a_key_from_the_environment() { + set("CIP5312_TEST_KEY_ENV", KEY_A); + let created = create(KeysetCreateOptions { + static_key_env: Some("CIP5312_TEST_KEY_ENV".into()), + ..create_options("static", "derived") + }) + .await + .unwrap(); + assert!(created.generated_static_key.is_none()); + + // The record opens with that key and not with another one. + let record = KeysetRecord::parse(&created.record).unwrap(); + let right = StaticKeyVendor::new("key-a", decode_static_key(KEY_A).unwrap()); + let wrong = StaticKeyVendor::new("key-a", decode_static_key(KEY_B).unwrap()); + assert!(record.rewrap(&right, "key-a").await.is_ok()); + assert!(record.rewrap(&wrong, "key-a").await.is_err()); + } + + #[tokio::test] + async fn rewraps_a_static_record_to_another_key() { + set("CIP5312_TEST_OLD", KEY_A); + set("CIP5312_TEST_NEW", KEY_B); + let created = create(KeysetCreateOptions { + static_key_env: Some("CIP5312_TEST_OLD".into()), + ..create_options("static", "derived") + }) + .await + .unwrap(); + let rewrapped = rewrap(KeysetRewrapOptions { + record: format!(" {}\n", created.record), + vendor_key: Some("key-b".into()), + static_key_env: Some("CIP5312_TEST_OLD".into()), + new_static_key_env: Some("CIP5312_TEST_NEW".into()), + }) + .await + .unwrap(); + assert_eq!(rewrapped.keyset_id, created.keyset_id); + assert_eq!(rewrapped.name, created.name); + assert_eq!(rewrapped.mode, "derived"); + assert_eq!(rewrapped.vendor_key, "key-b"); + + // Only the new key opens it now. + let record = KeysetRecord::parse(&rewrapped.record).unwrap(); + let new = StaticKeyVendor::new("key-b", decode_static_key(KEY_B).unwrap()); + let old = StaticKeyVendor::new("key-b", decode_static_key(KEY_A).unwrap()); + assert!(record.rewrap(&new, "key-b").await.is_ok()); + assert!(record.rewrap(&old, "key-b").await.is_err()); + } + + #[tokio::test] + async fn rewrap_with_a_wrong_static_key_is_a_vendor_error() { + set("CIP5312_TEST_RIGHT", KEY_A); + set("CIP5312_TEST_WRONG", KEY_B); + let created = create(KeysetCreateOptions { + static_key_env: Some("CIP5312_TEST_RIGHT".into()), + ..create_options("static", "derived") + }) + .await + .unwrap(); + let error = rewrap(KeysetRewrapOptions { + record: created.record, + vendor_key: None, + static_key_env: Some("CIP5312_TEST_WRONG".into()), + new_static_key_env: None, + }) + .await + .err() + .unwrap(); + let envelope = envelope(error); + assert_eq!(envelope["type"], "VENDOR_ERROR"); + assert_eq!(envelope["vendor"], "static"); + assert_eq!(envelope["kind"], "InvalidCiphertext"); + } + + #[tokio::test] + async fn refuses_an_unknown_key_mode() { + for backend in ["static", "aws"] { + let error = create(create_options(backend, "other")) + .await + .err() + .unwrap(); + let envelope = envelope(error); + assert_eq!(envelope["type"], "INVALID_OPTIONS", "{backend}"); + assert!( + envelope["message"].as_str().unwrap().contains("derived"), + "{backend}" + ); + } + } + + #[tokio::test] + async fn refuses_bad_options() { + let cases = [ + create_options("zerokms", "derived"), + create_options("aws", "both"), + KeysetCreateOptions { + vendor_key: String::new(), + ..create_options("aws", "derived") + }, + KeysetCreateOptions { + name: Some(String::new()), + ..create_options("aws", "derived") + }, + KeysetCreateOptions { + static_key_env: Some("CIP5312_TEST_ANY".into()), + ..create_options("aws", "derived") + }, + ]; + for options in cases { + let error = create(options.clone()).await.err().unwrap(); + assert_eq!(envelope(error)["type"], "INVALID_OPTIONS", "{options:?}"); + } + } + + #[tokio::test] + async fn rewrap_refuses_a_bad_record_and_missing_static_options() { + let error = rewrap(KeysetRewrapOptions { + record: "not-a-record".into(), + vendor_key: None, + static_key_env: None, + new_static_key_env: None, + }) + .await + .err() + .unwrap(); + let envelope_value = envelope(error); + assert_eq!(envelope_value["type"], "INVALID_RECORD"); + assert_eq!( + envelope_value["diagnostic"], + "stack_kms_vendor::record_prefix" + ); + + let created = create(create_options("static", "derived")).await.unwrap(); + let error = rewrap(KeysetRewrapOptions { + record: created.record.clone(), + vendor_key: None, + static_key_env: None, + new_static_key_env: None, + }) + .await + .err() + .unwrap(); + assert!(envelope(error)["message"] + .as_str() + .unwrap() + .contains("--key-env")); + + set( + "CIP5312_TEST_SAME", + created.generated_static_key.as_deref().unwrap(), + ); + let error = rewrap(KeysetRewrapOptions { + record: created.record, + vendor_key: None, + static_key_env: Some("CIP5312_TEST_SAME".into()), + new_static_key_env: Some("CIP5312_TEST_SAME".into()), + }) + .await + .err() + .unwrap(); + assert_eq!(envelope(error)["type"], "INVALID_OPTIONS"); + } + + /// The hand-written `KeysetFailure` union in `index.d.ts` lists every + /// code `KeysetError` emits. + #[test] + fn index_dts_lists_every_keyset_failure_code() { + let dts = include_str!("../index.d.ts"); + let errors = [ + KeysetError::Options(String::new()), + KeysetError::StaticKey(String::new()), + KeysetError::VendorConfig { + vendor: KeyVendor::Aws, + message: String::new(), + }, + KeysetError::Record(RecordError::NotARecord), + KeysetError::Record(RecordError::Kms(stack_kms::Error::Backend { + backend: KeyVendor::Static.into(), + kind: stack_kms::BackendErrorKind::NotFound, + source: "none".into(), + })), + ]; + for error in errors { + let code = error.code(); + assert!( + dts.contains(&format!("\"{code}\"")), + "index.d.ts KeysetFailure lacks {code}" + ); + } + assert!(dts.contains("export type KeysetFailure")); + } + + #[test] + fn the_backend_names_are_the_cli_names() { + let names: Vec<_> = BACKENDS.iter().map(|backend| backend.short).collect(); + assert_eq!(names, ["aws", "gcp", "azure", "vault", "static"]); + for backend in BACKENDS { + assert_eq!(parse_backend(backend.short).unwrap(), backend.vendor); + } + } + + /// Every vendor but the static key backend runs from the environment. + #[test] + fn every_vendor_client_backend_has_a_runner() { + for backend in BACKENDS { + assert_eq!( + backend.run.is_some(), + backend.vendor != KeyVendor::Static, + "{}", + backend.vendor + ); + } + } + + /// The input takes every name the output gives, and the table has every + /// vendor. + #[test] + fn the_backend_input_takes_the_output_names() { + for vendor in KeyVendor::ALL { + assert_eq!(parse_backend(vendor.name()).unwrap(), vendor); + assert!(BACKENDS.iter().any(|b| b.vendor == vendor), "{vendor}"); + } + let Err(KeysetError::Options(message)) = parse_backend("zerokms") else { + panic!("zerokms is not a key vendor backend"); + }; + assert_eq!( + message, + "Unknown key backend \"zerokms\". Use one of: aws, gcp, azure, vault, static. \ + The full names aws-kms, gcp-kms, azure-key-vault, vault-transit also work." + ); + } + + #[test] + fn a_vendor_config_error_names_the_vendor() { + let envelope = envelope(KeysetError::VendorConfig { + vendor: KeyVendor::Aws, + message: "no region".into(), + }); + assert_eq!(envelope["type"], "VENDOR_CONFIG"); + assert_eq!(envelope["vendor"], "aws-kms"); + assert!(envelope["message"].as_str().unwrap().contains("no region")); + } + + #[test] + fn the_result_debug_hides_the_generated_key() { + let result = KeysetResult { + record: "stack-keyset:x".into(), + keyset_id: "id".into(), + name: None, + backend: "static".into(), + mode: "derived".into(), + vendor_key: "key-a".into(), + generated_static_key: Some("the-secret-key".into()), + }; + assert!(!format!("{result:?}").contains("the-secret-key")); + } +} diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 67bd19b9c2..5c8977fb4e 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -13,6 +13,11 @@ use stack_auth::{ use vitaminc_protected::OpaqueDebug; use zeroize::Zeroizing; +mod keyset; +pub use keyset::{ + keyset_create, keyset_rewrap, KeysetCreateOptions, KeysetResult, KeysetRewrapOptions, +}; + // --------------------------------------------------------------------------- // Error helpers // --------------------------------------------------------------------------- diff --git a/languages/typescript/packages/cli/README.md b/languages/typescript/packages/cli/README.md index c051e26253..a5df6181a6 100644 --- a/languages/typescript/packages/cli/README.md +++ b/languages/typescript/packages/cli/README.md @@ -342,6 +342,19 @@ npx stash decrypt status The backfill loads your encryption client exactly as `encrypt backfill` does (client file or Prisma Next contract), so export the `CS_*` credentials of the environment that wrote the data. +### `npx stash keyset` — keysets in a key vendor backend + +**ZeroKMS users never need these commands.** They are for a keyset kept in a key vendor backend instead of ZeroKMS: AWS KMS, Google Cloud KMS, Azure Key Vault, HashiCorp Vault Transit, or a static key. Only the Rust crates and the Go SDK can use such a keyset today; `@cipherstash/stack` cannot yet. + +```bash +npx stash keyset create --backend aws --vendor-key arn:aws:kms:us-east-1:111122223333:key/ --name users +npx stash keyset rewrap --in keyset.env --out keyset.env --force +``` + +`keyset create` makes a **keyset record** and prints it as a dotenv line, `CS_KEYSET_RECORD_=stack-keyset:…` (or `CS_KEYSET_RECORD` with no `--name`). The record is not secret. No SDK reads that variable by itself: pass its value to `encrypt.WithKeysetRecords` (Go) or `KeysetRecord::parse` (Rust). The SDK derives the data keys from the record's root key, which it caches for a TTL (`--mode derived`, the default and the only key mode in this release). The static backend prints a generated key as `CS_STATIC_KEY` unless `--key-env` names the variable that holds one. Credentials come from each vendor's environment chain; `npx stash keyset create --help` lists the variables. + +`keyset rewrap` wraps the record's root key again, under the same vendor key after a rotation or under another key of the same backend. It does not re-key stored data, and every stored value still opens. The `stash-cli` agent skill has the full flag reference. + --- ## Migration mode diff --git a/languages/typescript/packages/cli/src/bin/main.ts b/languages/typescript/packages/cli/src/bin/main.ts index 47485afc59..ea3f195444 100644 --- a/languages/typescript/packages/cli/src/bin/main.ts +++ b/languages/typescript/packages/cli/src/bin/main.ts @@ -135,6 +135,9 @@ Commands: env Mint deployment credentials and print them as env vars + keyset create Create a keyset record for a key vendor backend (not needed with ZeroKMS) + keyset rewrap Rewrap a keyset record under the current or another vendor key (not needed with ZeroKMS) + Options: --help, -h Show help --version, -v Show version @@ -590,6 +593,70 @@ async function runSchemaCommand( } } +/** + * `stash keyset create|rewrap`. The module loads `@cipherstash/auth` only + * after its own argv checks, so a usage error never needs the native binary. + */ +async function runKeysetCommand( + sub: string | undefined, + commandArgs: string[], + flags: Record, + values: Record, +) { + const keyset = await import('../commands/keyset/index.js') + // parseArgs booleanises a value flag with no value (`--out --force`). + const missing = (names: string[]) => + names.filter((name) => flags[name] === true) + const output = { + out: values.out, + force: flags.force, + json: flags.json, + } + switch (sub) { + case 'create': + if (commandArgs.length > 0) { + p.log.error( + `Unexpected argument '${commandArgs[0]}'. stash keyset create takes flags only.`, + { output: process.stderr }, + ) + throw new CliExit(1) + } + await keyset.keysetCreateCommand( + { + ...output, + backend: values.backend, + vendorKey: values['vendor-key'], + mode: values.mode, + name: values.name, + keyEnv: values['key-env'], + }, + missing(['backend', 'vendor-key', 'mode', 'name', 'key-env', 'out']), + ) + break + case 'rewrap': + if (commandArgs.length > 1) { + p.log.error('stash keyset rewrap takes one keyset record.', { + output: process.stderr, + }) + throw new CliExit(1) + } + await keyset.keysetRewrapCommand( + { + ...output, + record: commandArgs[0], + in: values.in, + vendorKey: values['vendor-key'], + keyEnv: values['key-env'], + newKeyEnv: values['new-key-env'], + }, + missing(['in', 'vendor-key', 'key-env', 'new-key-env', 'out']), + ) + break + default: + keyset.keysetUnknownSubcommand(sub) + } +} + // The CLI body. Loaded by the thin launcher in stash.ts via dynamic import so // that a missing native binary (evaluated when this module's command graph // loads) surfaces as friendly guidance rather than a raw stack trace. @@ -722,6 +789,9 @@ async function dispatch( unexpectedArg: subcommand ?? commandArgs[0], }) break + case 'keyset': + await runKeysetCommand(subcommand, commandArgs, flags, values) + break case 'manifest': // Pure metadata (no native code) — safe to run anywhere, including when // the native binary is missing. diff --git a/languages/typescript/packages/cli/src/cli/registry.ts b/languages/typescript/packages/cli/src/cli/registry.ts index e052aa7122..6eadee5723 100644 --- a/languages/typescript/packages/cli/src/cli/registry.ts +++ b/languages/typescript/packages/cli/src/cli/registry.ts @@ -833,4 +833,167 @@ export const registry: CommandGroup[] = [ }, ], }, + { + title: 'Key vendor backends', + commands: [ + { + name: 'keyset create', + summary: + 'Create a keyset record for a key vendor backend (not needed with ZeroKMS)', + long: [ + 'ZeroKMS users never need this command. It is only for a keyset held', + 'by a key vendor backend: AWS KMS, Google Cloud KMS, Azure Key Vault,', + 'HashiCorp Vault Transit, or the static key backend.', + '', + 'Generates a keyset id and a key, wraps the key under --vendor-key', + '(one vendor call), and prints the keyset record as a dotenv line,', + 'CS_KEYSET_RECORD (or CS_KEYSET_RECORD_ with --name). The', + 'record is not secret: only the vendor key can unwrap its key. No SDK', + 'reads that variable by itself: pass its value to', + 'encrypt.WithKeysetRecords (Go) or KeysetRecord::parse (Rust).', + '', + 'Credentials come from the vendor environment, never from a flag:', + ' aws AWS_REGION; AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY', + ' (+ AWS_SESSION_TOKEN), web identity, ECS or IMDS.', + ' No ~/.aws profiles: export them first, for example with', + ' `aws configure export-credentials --format env`.', + ' gcp GOOGLE_OAUTH_ACCESS_TOKEN (e.g. `gcloud auth', + ' print-access-token`), else the metadata server. Service', + ' account key files are not read.', + ' azure AZURE_KEY_VAULT_ACCESS_TOKEN; AZURE_TENANT_ID +', + ' AZURE_CLIENT_ID + AZURE_CLIENT_SECRET or', + ' AZURE_FEDERATED_TOKEN_FILE; else managed identity.', + ' vault VAULT_ADDR (+ VAULT_NAMESPACE, VAULT_TRANSIT_MOUNT);', + ' VAULT_TOKEN, AppRole or Kubernetes auth.', + ' static --key-env names the variable that holds the 32-byte key', + ' (base64 or hex). Without it a new key is generated and', + ' printed as CS_STATIC_KEY, which is a secret.', + '', + 'Stdout carries only the dotenv block (or the --json event); progress', + 'goes to stderr, so `stash keyset create … > keyset.env` is safe.', + ].join('\n'), + examples: [ + 'keyset create --backend aws --vendor-key arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab --name users', + 'keyset create --backend vault --vendor-key users --out keyset.env', + 'keyset create --backend static --vendor-key dev-key --out .env.keyset.local', + ], + flags: [ + { + name: '--backend', + value: '', + description: + 'The key vendor backend. The full names that the output gives (aws-kms, gcp-kms, azure-key-vault, vault-transit) also work. Required.', + }, + { + name: '--vendor-key', + value: '', + description: + 'The vendor key that wraps the keyset key: an AWS KMS key ARN, a Cloud KMS crypto key name, an Azure Key Vault key identifier, a Vault Transit key name, or a label for a static key. Required.', + }, + { + name: '--mode', + value: '', + description: + 'The key mode. derived (the default and the only mode in this release): data keys come from a root key cached for a short time.', + }, + { + name: '--name', + value: '', + description: + 'A local name for the keyset (1 to 255 bytes). Also names the variable: CS_KEYSET_RECORD_.', + }, + { + name: '--key-env', + value: '', + description: + 'Static backend only: the environment variable that holds the static key. Omit it to generate a new key.', + }, + { + name: '--out', + value: '', + description: + 'Write the block to this file (mode 0600) instead of stdout. An existing file is refused unless --force.', + }, + { + name: '--force', + description: 'Overwrite an existing --out file.', + }, + { + name: '--json', + description: + 'Emit one NDJSON object: { status: "created", record, keysetId, envVar, … } (with a generated static key in staticKey), or { status: "written", path, … } with --out, which never carries the static key. Failures are { status: "error" }.', + }, + ], + }, + { + name: 'keyset rewrap', + summary: + 'Rewrap a keyset record under the current or another vendor key (not needed with ZeroKMS)', + long: [ + 'ZeroKMS users never need this command.', + '', + 'Unwraps the record key and wraps it again (two vendor calls). With', + 'no --vendor-key it uses the record’s own vendor key, which moves the', + 'record to that key’s current version after a rotation. With', + '--vendor-key it moves the record to another key of the same backend.', + 'The keyset id, name and mode stay the same. Stored data is not', + 're-keyed: every data key comes from the record’s root key, so a', + 'record may move to any key of its backend and every stored value', + 'still opens.', + '', + 'The record is the argument, or --in (a file that --out wrote,', + 'or the bare record). Credentials come from the vendor environment, as', + 'for `keyset create`. For a static record, --key-env names the', + 'variable that holds its current key, and --new-key-env the new', + 'key (omit it to generate one) when --vendor-key names another key.', + ].join('\n'), + examples: [ + 'keyset rewrap --in keyset.env --out keyset.env --force', + 'keyset rewrap stack-keyset:AQEC… --vendor-key arn:aws:kms:us-east-1:111122223333:key/new-key-id', + 'keyset rewrap --in .env.keyset.local --key-env CS_STATIC_KEY --vendor-key dev-key-2 --new-key-env CS_STATIC_KEY_2', + ], + flags: [ + { + name: '--in', + value: '', + description: + 'Read the record from this file instead of the argument. The file must hold exactly one record.', + }, + { + name: '--vendor-key', + value: '', + description: + 'The vendor key to wrap under. Default: the record’s own vendor key.', + }, + { + name: '--key-env', + value: '', + description: + 'Static backend only: the environment variable that holds the record’s current static key. Required for a static record.', + }, + { + name: '--new-key-env', + value: '', + description: + 'Static backend only: the environment variable that holds the static key of --vendor-key. Omit it to generate a new key.', + }, + { + name: '--out', + value: '', + description: + 'Write the block to this file (mode 0600) instead of stdout. An existing file is refused unless --force.', + }, + { + name: '--force', + description: 'Overwrite an existing --out file.', + }, + { + name: '--json', + description: + 'Emit one NDJSON object: { status: "rewrapped", … }, or { status: "written", … } with --out. Failures are { status: "error" }.', + }, + ], + }, + ], + }, ] diff --git a/languages/typescript/packages/cli/src/commands/keyset/__tests__/keyset.test.ts b/languages/typescript/packages/cli/src/commands/keyset/__tests__/keyset.test.ts new file mode 100644 index 0000000000..da163e89d4 --- /dev/null +++ b/languages/typescript/packages/cli/src/commands/keyset/__tests__/keyset.test.ts @@ -0,0 +1,430 @@ +import { + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from 'vitest' +import { CliExit } from '../../../cli/exit.js' +import { messages } from '../../../messages.js' + +// The vendor calls happen in the native binding. Fake it at that boundary, so +// the unit suite is native-free (the binding's own tests drive the static key +// backend for real). +const authMock = vi.hoisted(() => ({ + keysetCreate: vi.fn(), + keysetRewrap: vi.fn(), +})) +vi.mock('@cipherstash/auth', () => ({ default: authMock })) + +const clack = vi.hoisted(() => { + const spinnerInstance = { start: vi.fn(), stop: vi.fn() } + return { + spinnerInstance, + intro: vi.fn(), + outro: vi.fn(), + spinner: vi.fn(() => spinnerInstance), + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() }, + } +}) +vi.mock('@clack/prompts', () => clack) + +const { + formatBlock, + keysetCreateCommand, + keysetRewrapCommand, + readRecordFile, + recordVar, +} = await import('../index.js') + +const RECORD = 'stack-keyset:AQECAAAAAAAAAAAAAAAAAAAAAAA' + +function result(over: Record = {}) { + return { + record: RECORD, + keysetId: '0f0e0d0c-0b0a-4908-8706-050403020100', + name: 'users', + backend: 'aws-kms', + mode: 'derived', + vendorKey: 'arn:aws:kms:us-east-1:111122223333:key/k', + ...over, + } +} + +async function expectExit(promise: Promise, code: number) { + try { + await promise + } catch (err) { + expect(err).toBeInstanceOf(CliExit) + expect((err as CliExit).code).toBe(code) + return + } + throw new Error(`expected CliExit(${code}), but the command returned`) +} + +let stdout: MockInstance +let dir: string + +beforeEach(() => { + vi.clearAllMocks() + stdout = vi.spyOn(console, 'log').mockImplementation(() => {}) + dir = mkdtempSync(join(tmpdir(), 'stash-keyset-')) +}) + +afterEach(() => { + stdout.mockRestore() + rmSync(dir, { recursive: true, force: true }) +}) + +const printed = () => + stdout.mock.calls.map((call: unknown[]) => String(call[0])).join('\n') +const errors = () => + clack.log.error.mock.calls.map((call) => String(call[0])).join('\n') + +describe('stash keyset create', () => { + it('passes the flags to the native binding and prints a dotenv block', async () => { + authMock.keysetCreate.mockResolvedValue({ data: result() }) + await keysetCreateCommand({ + backend: 'aws', + vendorKey: 'arn:aws:kms:us-east-1:111122223333:key/k', + name: 'users', + }) + // --mode defaults to derived, the only key mode in this release. + expect(authMock.keysetCreate).toHaveBeenCalledWith({ + backend: 'aws', + vendorKey: 'arn:aws:kms:us-east-1:111122223333:key/k', + mode: 'derived', + name: 'users', + }) + const out = printed() + expect(out).toContain(`CS_KEYSET_RECORD_USERS=${RECORD}`) + expect(out).toContain('# backend: aws-kms, mode: derived') + // Stdout carries only the block: every line is a comment, blank or VAR=. + for (const line of out.split('\n')) { + expect( + line === '' || line.startsWith('#') || /^[A-Z_]+=/.test(line), + ).toBe(true) + } + }) + + it('refuses missing flags before loading the binding', async () => { + await expectExit(keysetCreateCommand({ backend: 'aws' }), 1) + expect(errors()).toContain(messages.keyset.missingCreateFlags) + expect(errors()).toContain('--vendor-key.') + expect(errors()).not.toContain('--mode') + expect(authMock.keysetCreate).not.toHaveBeenCalled() + }) + + it('refuses an unknown backend and says ZeroKMS needs no keyset', async () => { + await expectExit( + keysetCreateCommand({ + backend: 'zerokms', + vendorKey: 'k', + mode: 'derived', + }), + 1, + ) + expect(errors()).toContain(messages.keyset.unknownBackend) + expect(errors()).toContain( + 'Use one of: aws, gcp, azure, vault, static. The full names aws-kms, gcp-kms, azure-key-vault, vault-transit also work.', + ) + const hints = clack.log.info.mock.calls.map((call) => String(call[0])) + expect(hints).toContain(messages.keyset.zeroKmsNote) + }) + + it('takes the backend name that its own output gives', async () => { + authMock.keysetCreate.mockResolvedValue({ data: result() }) + await keysetCreateCommand({ + backend: 'aws-kms', + vendorKey: 'arn:aws:kms:us-east-1:111122223333:key/k', + mode: 'derived', + }) + expect(authMock.keysetCreate).toHaveBeenCalledWith( + expect.objectContaining({ backend: 'aws-kms' }), + ) + }) + + it('refuses an unknown key mode on every backend', async () => { + for (const backend of ['static', 'aws']) { + await expectExit( + keysetCreateCommand({ + backend, + vendorKey: 'k', + mode: 'other', + json: true, + }), + 1, + ) + expect(JSON.parse(printed())).toMatchObject({ + status: 'error', + code: 'invalid_mode', + message: "Unknown key mode 'other'. Use derived.", + }) + stdout.mockClear() + } + expect(authMock.keysetCreate).not.toHaveBeenCalled() + }) + + it('takes a variable NAME in --key-env, never a key', async () => { + await expectExit( + keysetCreateCommand({ + backend: 'static', + vendorKey: 'k', + mode: 'derived', + keyEnv: 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=', + }), + 1, + ) + expect(errors()).toContain('NAME of an environment variable') + // The refused value is not echoed. + expect(errors()).not.toContain('AAECAwQF') + + await expectExit( + keysetCreateCommand({ + backend: 'aws', + vendorKey: 'k', + mode: 'derived', + keyEnv: 'CS_STATIC_KEY', + }), + 1, + ) + expect(errors()).toContain('--key-env is only for --backend static') + }) + + it('refuses a name with a control character', async () => { + await expectExit( + keysetCreateCommand({ + backend: 'aws', + vendorKey: 'k', + mode: 'derived', + name: 'a\nCS_X=1', + }), + 1, + ) + expect(authMock.keysetCreate).not.toHaveBeenCalled() + }) + + it('reports a flag that lost its value', async () => { + await expectExit(keysetCreateCommand({}, ['out']), 1) + expect(errors()).toContain('--out requires a value') + }) + + it('maps a native failure to an error and a hint', async () => { + const error = new Error( + 'Could not configure the aws-kms client from the environment: no region', + ) + authMock.keysetCreate.mockResolvedValue({ + failure: { type: 'VENDOR_CONFIG', error, vendor: 'aws-kms' }, + }) + await expectExit( + keysetCreateCommand({ backend: 'aws', vendorKey: 'k', mode: 'derived' }), + 1, + ) + expect(errors()).toContain('no region') + const hints = clack.log.info.mock.calls.map((call) => String(call[0])) + expect(hints).toContain(messages.keyset.vendorConfigHint) + }) + + it('emits the --json error envelope with the native code', async () => { + authMock.keysetCreate.mockResolvedValue({ + failure: { + type: 'VENDOR_ERROR', + kind: 'AccessDenied', + error: new Error('denied'), + }, + }) + await expectExit( + keysetCreateCommand({ + backend: 'aws', + vendorKey: 'k', + mode: 'derived', + json: true, + }), + 1, + ) + const event = JSON.parse(printed()) + expect(event).toMatchObject({ + status: 'error', + code: 'vendor_error', + message: 'denied', + }) + expect(event.hint).toContain('credentials') + }) + + it('prints a generated static key and warns that it is a secret', async () => { + authMock.keysetCreate.mockResolvedValue({ + data: result({ + backend: 'static', + mode: 'derived', + name: undefined, + vendorKey: 'dev-key', + generatedStaticKey: 'c2VjcmV0LXN0YXRpYy1rZXktZm9yLXRlc3Rpbmc=', + }), + }) + await keysetCreateCommand({ + backend: 'static', + vendorKey: 'dev-key', + mode: 'derived', + }) + expect(authMock.keysetCreate).toHaveBeenCalledWith({ + backend: 'static', + vendorKey: 'dev-key', + mode: 'derived', + }) + const out = printed() + expect(out).toContain(`CS_KEYSET_RECORD=${RECORD}`) + expect(out).toContain( + 'CS_STATIC_KEY=c2VjcmV0LXN0YXRpYy1rZXktZm9yLXRlc3Rpbmc=', + ) + expect(clack.log.warn).toHaveBeenCalledWith( + messages.keyset.generatedStaticKeyWarning, + expect.anything(), + ) + }) + + it('writes --out with mode 0600, refuses to overwrite without --force', async () => { + authMock.keysetCreate.mockResolvedValue({ + data: result({ generatedStaticKey: 'c2VjcmV0' }), + }) + const out = join(dir, 'keyset.env') + const options = { + backend: 'aws', + vendorKey: 'k', + mode: 'derived', + out, + json: true, + } + await keysetCreateCommand(options) + expect(statSync(out).mode & 0o777).toBe(0o600) + expect(readFileSync(out, 'utf-8')).toContain( + `CS_KEYSET_RECORD_USERS=${RECORD}`, + ) + // The --json confirmation never carries the static key. + const event = JSON.parse(printed()) + expect(event).toMatchObject({ + status: 'written', + path: out, + staticKeyVar: 'CS_STATIC_KEY', + }) + expect(JSON.stringify(event)).not.toContain('c2VjcmV0') + + stdout.mockClear() + await expectExit(keysetCreateCommand(options), 1) + expect(JSON.parse(printed())).toMatchObject({ code: 'write_conflict' }) + + await keysetCreateCommand({ ...options, force: true }) + }) + + it('tells the user when the installed binding is too old', async () => { + const { keysetCreate } = authMock + // biome-ignore lint/suspicious/noExplicitAny: removing a mocked method. + ;(authMock as any).keysetCreate = undefined + try { + await expectExit( + keysetCreateCommand({ + backend: 'aws', + vendorKey: 'k', + mode: 'derived', + }), + 1, + ) + expect(errors()).toContain('has no keyset functions') + } finally { + authMock.keysetCreate = keysetCreate + } + }) +}) + +describe('stash keyset rewrap', () => { + it('reads the record from --in and passes the static key variables', async () => { + const file = join(dir, 'keyset.env') + writeFileSync(file, formatBlock(result(), 'CS_KEYSET_RECORD_USERS')) + authMock.keysetRewrap.mockResolvedValue({ + data: result({ vendorKey: 'b' }), + }) + await keysetRewrapCommand({ + in: file, + vendorKey: 'b', + keyEnv: 'OLD_KEY', + newKeyEnv: 'NEW_KEY', + }) + expect(authMock.keysetRewrap).toHaveBeenCalledWith({ + record: RECORD, + vendorKey: 'b', + staticKeyEnv: 'OLD_KEY', + newStaticKeyEnv: 'NEW_KEY', + }) + const hints = clack.log.info.mock.calls.map((call) => String(call[0])) + expect(hints).toContain(messages.keyset.rewrapNote) + }) + + it('takes the record as an argument, trimmed', async () => { + authMock.keysetRewrap.mockResolvedValue({ data: result() }) + await keysetRewrapCommand({ record: ` ${RECORD}\n`, json: true }) + expect(authMock.keysetRewrap).toHaveBeenCalledWith({ record: RECORD }) + expect(JSON.parse(printed())).toMatchObject({ + status: 'rewrapped', + record: RECORD, + envVar: 'CS_KEYSET_RECORD_USERS', + }) + }) + + it('refuses no record, two sources, and text that is not a record', async () => { + await expectExit(keysetRewrapCommand({}), 1) + expect(errors()).toContain(messages.keyset.missingRecord) + await expectExit(keysetRewrapCommand({ record: RECORD, in: 'x' }), 1) + await expectExit(keysetRewrapCommand({ record: 'hello' }), 1) + expect(authMock.keysetRewrap).not.toHaveBeenCalled() + }) + + it('rewrites the --in file in place with --force', async () => { + const file = join(dir, 'keyset.env') + writeFileSync(file, `${RECORD}\n`) + const next = 'stack-keyset:AQECAQEBAQEBAQEBAQEBAQEBAQE' + authMock.keysetRewrap.mockResolvedValue({ data: result({ record: next }) }) + await keysetRewrapCommand({ in: file, out: file, force: true }) + expect(readFileSync(file, 'utf-8')).toContain(next) + expect(statSync(file).mode & 0o777).toBe(0o600) + }) +}) + +describe('readRecordFile', () => { + it('refuses a file with no record or with two', () => { + const none = join(dir, 'none') + writeFileSync(none, 'FOO=bar\n') + expect(() => readRecordFile(none)).toThrow('holds no keyset record') + const two = join(dir, 'two') + writeFileSync(two, `A=${RECORD}\nB=stack-keyset:BBBB\n`) + expect(() => readRecordFile(two)).toThrow('holds 2 keyset records') + }) +}) + +describe('recordVar and formatBlock', () => { + it('derives the variable from the name', () => { + expect(recordVar(undefined)).toBe('CS_KEYSET_RECORD') + expect(recordVar('users')).toBe('CS_KEYSET_RECORD_USERS') + expect(recordVar('billing-eu 2')).toBe('CS_KEYSET_RECORD_BILLING_EU_2') + expect(recordVar('---')).toBe('CS_KEYSET_RECORD') + }) + + it('quotes a name from a record so it cannot leave its comment line', () => { + const block = formatBlock( + result({ name: 'x\nEVIL=1' }), + 'CS_KEYSET_RECORD_X', + ) + expect(block.split('\n').some((line) => line.startsWith('EVIL='))).toBe( + false, + ) + }) +}) diff --git a/languages/typescript/packages/cli/src/commands/keyset/index.ts b/languages/typescript/packages/cli/src/commands/keyset/index.ts new file mode 100644 index 0000000000..35b2c40ad9 --- /dev/null +++ b/languages/typescript/packages/cli/src/commands/keyset/index.ts @@ -0,0 +1,568 @@ +import { chmodSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { CliExit } from '../../cli/exit.js' +import { messages } from '../../messages.js' +import { emitJsonError, emitJsonEvent } from '../auth/events.js' + +/** + * `stash keyset create` / `stash keyset rewrap` (CIP-5312): keyset records for + * the key vendor backends (AWS KMS, Google Cloud KMS, Azure Key Vault, Vault + * Transit, the static key backend). ZeroKMS users never need them. + * + * The vendor calls are made by Rust, through `keysetCreate` / `keysetRewrap` + * in the `@cipherstash/auth` native binding, with the same vendor clients the + * Rust and Go SDKs use. Credentials come from each vendor's environment chain; + * no flag carries a secret. A static key is named by the environment variable + * that holds it (`--key-env`), never passed as a value. + * + * As with `stash env`, every human-facing line goes to STDERR, and stdout + * carries only the dotenv block (or, with `--json`, one NDJSON event), so + * `stash keyset create … > keyset.env` stays clean. + */ +const CHROME = { output: process.stderr } as const + +/** + * The `--backend` values: each short name, and the full name that the output + * (`# backend: …`, the `backend` of `--json`) and the SDKs use. `--backend` + * takes either, so a value the output gives is a valid input. The Rust side + * (`BACKENDS` in `@cipherstash/auth`) holds the same table. + */ +export const KEYSET_BACKEND_NAMES = { + aws: 'aws-kms', + gcp: 'gcp-kms', + azure: 'azure-key-vault', + vault: 'vault-transit', + static: 'static', +} as const + +/** The short `--backend` names, in the order the help text lists them. */ +export const KEYSET_BACKENDS = Object.keys(KEYSET_BACKEND_NAMES) as Array< + keyof typeof KEYSET_BACKEND_NAMES +> + +/** The full names that are not also a short name. */ +const KEYSET_FULL_BACKENDS = Object.values(KEYSET_BACKEND_NAMES).filter( + (name) => !(KEYSET_BACKENDS as readonly string[]).includes(name), +) + +/** Whether `--backend` takes `value`. */ +export function isKeysetBackend(value: string): boolean { + return ( + (KEYSET_BACKENDS as readonly string[]).includes(value) || + (KEYSET_FULL_BACKENDS as readonly string[]).includes(value) + ) +} + +/** + * The `--mode` values. `derived` is the only key mode in this release, and + * the default. + */ +export const KEYSET_MODES = ['derived'] as const + +/** + * The prefix of the dotenv variable the record is printed as. No SDK reads it + * by itself today: the app passes its value to `encrypt.WithKeysetRecords` + * (Go) or `KeysetRecord::parse` (Rust). + */ +export const RECORD_VAR = 'CS_KEYSET_RECORD' + +/** The dotenv variable a generated static key is printed as. */ +export const STATIC_KEY_VAR = 'CS_STATIC_KEY' + +/** Every keyset record starts with this. */ +const RECORD_PREFIX = 'stack-keyset:' +const RECORD_PATTERN = /stack-keyset:[A-Za-z0-9_-]+/g + +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching control characters is the point. +const CONTROL_CHARS = /[\x00-\x1f\x7f]/ + +/** The flags shared by both subcommands. */ +interface OutputOptions { + /** Write the block to this file (mode 0600) instead of stdout. */ + out?: string + /** Overwrite an existing `--out` file. */ + force?: boolean + /** Emit one NDJSON event instead of a dotenv block. */ + json?: boolean +} + +export interface KeysetCreateOptions extends OutputOptions { + backend?: string + vendorKey?: string + mode?: string + name?: string + keyEnv?: string +} + +export interface KeysetRewrapOptions extends OutputOptions { + /** The record, as the positional argument. */ + record?: string + /** A file holding the record (a dotenv block from `--out`, or the bare record). */ + in?: string + vendorKey?: string + keyEnv?: string + newKeyEnv?: string +} + +/** A value-taking flag that argv parsing turned into a boolean (no value). */ +export type MissingValues = string[] + +/** What the native binding returns (`KeysetResult` in `@cipherstash/auth`). */ +interface KeysetResult { + record: string + keysetId: string + name?: string + backend: string + mode: string + vendorKey: string + generatedStaticKey?: string +} + +interface KeysetFailure { + type: string + error: Error + help?: string + vendor?: string + kind?: string +} + +type NativeResult = + | { data: KeysetResult; failure?: undefined } + | { data?: undefined; failure: KeysetFailure } + +/** An error with a code for the `--json` stream and an optional hint. */ +export class KeysetError extends Error { + constructor( + readonly code: string, + message: string, + readonly hint?: string, + ) { + super(message) + this.name = 'KeysetError' + } +} + +// --------------------------------------------------------------------------- +// Entry points +// --------------------------------------------------------------------------- + +export async function keysetCreateCommand( + options: KeysetCreateOptions, + missingValues: MissingValues = [], +): Promise { + await run('create', options, async () => { + rejectMissingValues(missingValues) + const input = validateCreate(options) + const outPath = resolveOut(options) + const auth = await loadNative() + const result = (await auth.keysetCreate(input)) as NativeResult + return { result: unwrap(result), outPath } + }) +} + +export async function keysetRewrapCommand( + options: KeysetRewrapOptions, + missingValues: MissingValues = [], +): Promise { + await run('rewrap', options, async () => { + rejectMissingValues(missingValues) + const input = validateRewrap(options) + const outPath = resolveOut(options) + const auth = await loadNative() + const result = (await auth.keysetRewrap(input)) as NativeResult + return { result: unwrap(result), outPath } + }) +} + +/** Unknown `stash keyset `. */ +export function keysetUnknownSubcommand(sub: string | undefined): never { + p.log.error( + `${messages.keyset.unknownSubcommand}: ${sub ?? '(none)'}`, + CHROME, + ) + p.log.info(messages.keyset.zeroKmsNote, CHROME) + throw new CliExit(1) +} + +// --------------------------------------------------------------------------- +// The run loop: one place renders success and failure for both subcommands +// --------------------------------------------------------------------------- + +async function run( + verb: 'create' | 'rewrap', + options: OutputOptions, + body: () => Promise<{ result: KeysetResult; outPath: string | undefined }>, +): Promise { + const json = options.json ?? false + if (!json) p.intro(`stash keyset ${verb}`, CHROME) + try { + const s = json ? null : p.spinner(CHROME) + let outcome: { result: KeysetResult; outPath: string | undefined } + s?.start( + verb === 'create' ? 'Creating the keyset...' : 'Rewrapping the keyset...', + ) + try { + outcome = await body() + } catch (err) { + s?.stop( + verb === 'create' + ? 'Could not create the keyset.' + : 'Could not rewrap the keyset.', + ) + throw err + } + s?.stop(verb === 'create' ? 'Keyset created.' : 'Keyset rewrapped.') + render(verb, outcome.result, outcome.outPath, options) + } catch (err) { + if (err instanceof CliExit) throw err + const failure = + err instanceof KeysetError + ? err + : new KeysetError( + 'keyset_failed', + err instanceof Error ? err.message : String(err), + ) + if (json) { + emitJsonError(failure.code, failure.message, failure.hint) + } else { + p.log.error(failure.message, CHROME) + if (failure.hint) p.log.info(failure.hint, CHROME) + } + throw new CliExit(1) + } +} + +function render( + verb: 'create' | 'rewrap', + result: KeysetResult, + outPath: string | undefined, + options: OutputOptions, +): void { + const envVar = recordVar(result.name) + const block = formatBlock(result, envVar) + const event = { + keysetId: result.keysetId, + ...(result.name !== undefined ? { name: result.name } : {}), + backend: result.backend, + mode: result.mode, + vendorKey: result.vendorKey, + envVar, + record: result.record, + } + const status = verb === 'create' ? 'created' : 'rewrapped' + + if (outPath) { + writeOut(outPath, block, options.force ?? false) + if (options.json) { + // Secret-free, as `stash env --write --json` is: a generated static key + // is in the 0600 file only. + emitJsonEvent({ + status: 'written', + path: outPath, + ...event, + ...(result.generatedStaticKey ? { staticKeyVar: STATIC_KEY_VAR } : {}), + }) + return + } + p.log.success(`Wrote ${outPath}`, CHROME) + } else if (options.json) { + emitJsonEvent({ + status, + ...event, + ...(result.generatedStaticKey + ? { + staticKeyVar: STATIC_KEY_VAR, + staticKey: result.generatedStaticKey, + } + : {}), + }) + return + } else { + console.log(block) + } + + if (result.generatedStaticKey) { + p.log.warn(messages.keyset.generatedStaticKeyWarning, CHROME) + } + p.log.info( + `No SDK reads ${envVar} by itself: pass its value to encrypt.WithKeysetRecords (Go) or KeysetRecord::parse (Rust).`, + CHROME, + ) + if (verb === 'rewrap') { + p.log.info(messages.keyset.rewrapNote, CHROME) + } + p.outro('Done!', CHROME) +} + +// --------------------------------------------------------------------------- +// Validation (no native code: these run before the binding loads) +// --------------------------------------------------------------------------- + +function rejectMissingValues(missing: MissingValues): void { + if (missing.length > 0) { + throw new KeysetError( + 'missing_flag_value', + `${missing.map((flag) => `--${flag}`).join(', ')} ${missing.length === 1 ? 'requires' : 'require'} a value.`, + ) + } +} + +function validateCreate(options: KeysetCreateOptions) { + const missing = [ + !options.backend && '--backend', + !options.vendorKey && '--vendor-key', + ].filter(Boolean) + if (missing.length > 0) { + throw new KeysetError( + 'missing_flag', + `${messages.keyset.missingCreateFlags}: ${missing.join(', ')}.`, + 'For example: stash keyset create --backend aws --vendor-key ', + ) + } + const backend = options.backend as string + if (!isKeysetBackend(backend)) { + throw new KeysetError( + 'invalid_backend', + `${messages.keyset.unknownBackend} '${backend}'. Use one of: ${KEYSET_BACKENDS.join(', ')}. The full names ${KEYSET_FULL_BACKENDS.join(', ')} also work.`, + messages.keyset.zeroKmsNote, + ) + } + const mode = options.mode ?? 'derived' + if (!(KEYSET_MODES as readonly string[]).includes(mode)) { + throw new KeysetError( + 'invalid_mode', + `Unknown key mode '${mode}'. Use derived.`, + ) + } + if (backend !== 'static' && options.keyEnv !== undefined) { + throw new KeysetError( + 'invalid_option', + '--key-env is only for --backend static.', + ) + } + rejectControlChars('--vendor-key', options.vendorKey) + rejectControlChars('--name', options.name) + rejectEnvName('--key-env', options.keyEnv) + return { + backend, + vendorKey: options.vendorKey as string, + mode, + ...(options.name !== undefined ? { name: options.name } : {}), + ...(options.keyEnv !== undefined ? { staticKeyEnv: options.keyEnv } : {}), + } +} + +function validateRewrap(options: KeysetRewrapOptions) { + if (options.record !== undefined && options.in !== undefined) { + throw new KeysetError( + 'invalid_option', + 'Pass the record as an argument or with --in, not both.', + ) + } + let record: string + if (options.in !== undefined) { + record = readRecordFile(options.in) + } else if (options.record !== undefined) { + record = options.record.trim() + } else { + throw new KeysetError( + 'missing_record', + messages.keyset.missingRecord, + 'For example: stash keyset rewrap --in keyset.env --out keyset.env --force', + ) + } + if (!record.startsWith(RECORD_PREFIX)) { + throw new KeysetError( + 'invalid_record', + `This is not a keyset record: it does not start with ${RECORD_PREFIX}`, + ) + } + rejectControlChars('--vendor-key', options.vendorKey) + rejectEnvName('--key-env', options.keyEnv) + rejectEnvName('--new-key-env', options.newKeyEnv) + return { + record, + ...(options.vendorKey !== undefined + ? { vendorKey: options.vendorKey } + : {}), + ...(options.keyEnv !== undefined ? { staticKeyEnv: options.keyEnv } : {}), + ...(options.newKeyEnv !== undefined + ? { newStaticKeyEnv: options.newKeyEnv } + : {}), + } +} + +/** + * Read the one keyset record in `path`: a dotenv block that `--out` wrote, or + * a file holding the bare record. + */ +export function readRecordFile(path: string): string { + let text: string + try { + text = readFileSync(resolve(process.cwd(), path), 'utf-8') + } catch (err) { + throw new KeysetError( + 'read_failed', + `Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`, + ) + } + const records = [...new Set(text.match(RECORD_PATTERN) ?? [])] + if (records.length === 0) { + throw new KeysetError( + 'invalid_record', + `${path} holds no keyset record (no ${RECORD_PREFIX} text).`, + ) + } + if (records.length > 1) { + throw new KeysetError( + 'invalid_record', + `${path} holds ${records.length} keyset records. Rewrap them one at a time: pass each record as an argument.`, + ) + } + return records[0] as string +} + +function rejectControlChars(flag: string, value: string | undefined): void { + if (value !== undefined && CONTROL_CHARS.test(value)) { + throw new KeysetError( + 'invalid_option', + `${flag} must not contain newlines or control characters.`, + ) + } +} + +/** `--key-env` names a variable; it never holds the key itself. */ +function rejectEnvName(flag: string, value: string | undefined): void { + if (value !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) { + throw new KeysetError( + 'invalid_option', + `${flag} takes the NAME of an environment variable (for example ${flag} ${STATIC_KEY_VAR}), not the key itself.`, + ) + } +} + +function resolveOut(options: OutputOptions): string | undefined { + return options.out === undefined + ? undefined + : resolve(process.cwd(), options.out) +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +/** + * The dotenv variable for a record: `CS_KEYSET_RECORD`, or + * `CS_KEYSET_RECORD_` for a named keyset. + */ +export function recordVar(name: string | undefined): string { + const suffix = (name ?? '') + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + return suffix ? `${RECORD_VAR}_${suffix}` : RECORD_VAR +} + +/** + * The dotenv block. Values in comments are JSON-quoted, so a name or key + * reference from a record cannot break out of its comment line. + */ +export function formatBlock(result: KeysetResult, envVar: string): string { + const lines = [ + '# CipherStash keyset record for a key vendor backend.', + `# backend: ${result.backend}, mode: ${result.mode}`, + `# keyset id: ${result.keysetId}${result.name !== undefined ? `, name: ${JSON.stringify(result.name)}` : ''}`, + `# vendor key: ${JSON.stringify(result.vendorKey)}`, + '# The record is not secret. Only the vendor key can unwrap its key.', + `${envVar}=${result.record}`, + ] + if (result.generatedStaticKey) { + lines.push( + '', + `# The static key for vendor key ${JSON.stringify(result.vendorKey)}. THIS IS A SECRET:`, + '# keep it in a secret store. It is the only copy, and without it the keyset cannot be read.', + `${STATIC_KEY_VAR}=${result.generatedStaticKey}`, + ) + } + return lines.join('\n') +} + +function writeOut(path: string, block: string, force: boolean): void { + try { + // `wx` refuses an existing file atomically; `--force` overwrites it. + writeFileSync(path, `${block}\n`, { + encoding: 'utf-8', + mode: 0o600, + flag: force ? 'w' : 'wx', + }) + chmodSync(path, 0o600) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') { + throw new KeysetError( + 'write_conflict', + `${path} already exists. Pass --force to overwrite it, or choose another path.`, + ) + } + throw new KeysetError( + 'write_failed', + `Could not write ${path}: ${err instanceof Error ? err.message : String(err)}`, + ) + } +} + +// --------------------------------------------------------------------------- +// The native binding +// --------------------------------------------------------------------------- + +interface NativeKeyset { + keysetCreate(options: unknown): Promise + keysetRewrap(options: unknown): Promise +} + +async function loadNative(): Promise { + const mod: { default?: unknown } = await import('@cipherstash/auth') + const auth = mod.default ?? mod + if (!hasKeysetFunctions(auth)) { + throw new KeysetError( + 'native_outdated', + 'The installed @cipherstash/auth has no keyset functions. Install the @cipherstash/auth version this stash release pins.', + ) + } + return auth +} + +function hasKeysetFunctions(value: unknown): value is NativeKeyset { + if (typeof value !== 'object' || value === null) return false + const candidate = value as Record + return ( + typeof candidate.keysetCreate === 'function' && + typeof candidate.keysetRewrap === 'function' + ) +} + +function unwrap(result: NativeResult): KeysetResult { + if (result.failure) { + const failure = result.failure + throw new KeysetError( + failure.type.toLowerCase(), + failure.error.message, + failure.help ?? hintFor(failure), + ) + } + return result.data +} + +/** A remedy for the failures that have an obvious one. */ +function hintFor(failure: KeysetFailure): string | undefined { + switch (failure.type) { + case 'VENDOR_CONFIG': + return messages.keyset.vendorConfigHint + case 'VENDOR_ERROR': + return failure.kind === 'AccessDenied' + ? 'Check that the credentials in this environment may use the vendor key (encrypt and decrypt).' + : undefined + default: + return undefined + } +} diff --git a/languages/typescript/packages/cli/src/messages.ts b/languages/typescript/packages/cli/src/messages.ts index a6a5776c03..89200d73dc 100644 --- a/languages/typescript/packages/cli/src/messages.ts +++ b/languages/typescript/packages/cli/src/messages.ts @@ -451,6 +451,25 @@ export const messages = { nameRequiresValue: '--name requires a value', unexpectedArgument: 'Unexpected argument', }, + keyset: { + /** + * `stash keyset` copy that the unit and e2e suites assert on. Every one + * of these fires before the native binding loads or a vendor is called. + */ + zeroKmsNote: + 'ZeroKMS users never need stash keyset: it is only for keysets in a key vendor backend (AWS KMS, Google Cloud KMS, Azure Key Vault, Vault Transit, or a static key).', + unknownSubcommand: 'Unknown keyset command', + missingCreateFlags: 'stash keyset create needs', + unknownBackend: 'Unknown key backend', + missingRecord: + 'Pass the keyset record to rewrap, as an argument or with --in ', + generatedStaticKeyWarning: + 'A new static key was generated (CS_STATIC_KEY). It is a secret and the only copy: put it in a secret store now. Without it, nothing sealed under this keyset can be read.', + rewrapNote: + 'Replace the old record with the new one in every app. Stored values are not re-keyed, and they stay readable: a record may move to any key of its backend.', + vendorConfigHint: + 'The vendor client reads its settings and credentials from the environment, as the SDKs do. Run `stash keyset create --help` for the variables of each backend.', + }, plan: { /** * Stable leader of the refusal shown when `plan --complete-rollout` runs diff --git a/languages/typescript/packages/cli/tests/e2e/keyset.e2e.test.ts b/languages/typescript/packages/cli/tests/e2e/keyset.e2e.test.ts new file mode 100644 index 0000000000..b087987e38 --- /dev/null +++ b/languages/typescript/packages/cli/tests/e2e/keyset.e2e.test.ts @@ -0,0 +1,159 @@ +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { messages } from '../../src/messages.js' +import { run } from '../helpers/run.js' +import { runPiped } from '../helpers/spawn-piped.js' + +/** + * `stash keyset create` / `rewrap` end to end. The vendor backends need cloud + * credentials, so the round trip uses the static key backend, which runs the + * real native binding (`keysetCreate` / `keysetRewrap` in @cipherstash/auth) + * with no network. The vendor clients are tested in their own crates. + */ + +/** A fixed, test-only static key (bytes 0..31), and a second one. */ +const TEST_ONLY_KEY_A = 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=' +const TEST_ONLY_KEY_B = 'ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=' + +const dir = mkdtempSync(join(tmpdir(), 'stash-keyset-e2e-')) +afterAll(() => rmSync(dir, { recursive: true, force: true })) + +function jsonLine(stdout: string): Record { + const line = stdout + .split('\n') + .map((l) => l.trim()) + .find((l) => l.startsWith('{')) + if (!line) throw new Error(`no JSON line in stdout: ${stdout}`) + return JSON.parse(line) as Record +} + +describe('stash keyset', () => { + it('help says ZeroKMS users never need it', async () => { + const r = await run(['keyset', 'create', '--help'], { + env: { npm_config_user_agent: '' }, + }) + expect(r.exitCode).toBe(0) + expect(r.output).toContain('Usage: npx stash keyset create [options]') + expect(r.output).toContain('ZeroKMS users never need this command') + for (const flag of ['--backend', '--vendor-key', '--mode', '--key-env']) { + expect(r.output).toContain(flag) + } + }) + + it('lists both subcommands in the group help and the banner', async () => { + const group = await run(['keyset', '--help'], { + env: { npm_config_user_agent: '' }, + }) + expect(group.output).toContain('keyset create') + expect(group.output).toContain('keyset rewrap') + const banner = await run(['--help']) + expect(banner.output).toContain('keyset create') + }) + + it('an unknown subcommand exits 1 with the ZeroKMS note', async () => { + const r = await runPiped(['keyset', 'bogus'], { timeoutMs: 8000 }) + expect(r.exitCode).toBe(1) + expect(r.stderr).toContain(messages.keyset.unknownSubcommand) + expect(r.stderr).toContain('ZeroKMS users never need stash keyset') + }) + + it('missing flags fail with a JSON error before any vendor call', async () => { + const r = await runPiped( + ['keyset', 'create', '--backend', 'aws', '--json'], + { + timeoutMs: 8000, + }, + ) + expect(r.exitCode).toBe(1) + expect(jsonLine(r.stdout)).toMatchObject({ + status: 'error', + code: 'missing_flag', + }) + }) + + it('creates a static keyset record and rewraps it to another key', async () => { + const env = { + CS_TEST_ONLY_KEY_A: TEST_ONLY_KEY_A, + CS_TEST_ONLY_KEY_B: TEST_ONLY_KEY_B, + } + const out = join(dir, 'keyset.env') + const created = await runPiped( + [ + 'keyset', + 'create', + '--backend', + 'static', + '--vendor-key', + 'key-a', + '--mode', + 'derived', + '--name', + 'users', + '--key-env', + 'CS_TEST_ONLY_KEY_A', + '--out', + out, + ], + { env, timeoutMs: 15000 }, + ) + expect(created.stderr).not.toContain('Error') + expect(created.exitCode).toBe(0) + expect(statSync(out).mode & 0o777).toBe(0o600) + const block = readFileSync(out, 'utf-8') + const record = /^CS_KEYSET_RECORD_USERS=(stack-keyset:\S+)$/m.exec( + block, + )?.[1] + expect(record).toBeDefined() + // The key came from the environment, so none is written. + expect(block).not.toContain('CS_STATIC_KEY=') + expect(block).not.toContain(TEST_ONLY_KEY_A) + + const rewrapped = await runPiped( + [ + 'keyset', + 'rewrap', + '--in', + out, + '--key-env', + 'CS_TEST_ONLY_KEY_A', + '--vendor-key', + 'key-b', + '--new-key-env', + 'CS_TEST_ONLY_KEY_B', + '--json', + ], + { env, timeoutMs: 15000 }, + ) + expect(rewrapped.exitCode).toBe(0) + const event = jsonLine(rewrapped.stdout) + expect(event).toMatchObject({ + status: 'rewrapped', + name: 'users', + backend: 'static', + mode: 'derived', + vendorKey: 'key-b', + envVar: 'CS_KEYSET_RECORD_USERS', + }) + expect(event.record).not.toBe(record) + + // The old key no longer opens the rewrapped record. + const wrong = await runPiped( + [ + 'keyset', + 'rewrap', + String(event.record), + '--key-env', + 'CS_TEST_ONLY_KEY_A', + '--json', + ], + { env, timeoutMs: 15000 }, + ) + expect(wrong.exitCode).toBe(1) + expect(jsonLine(wrong.stdout)).toMatchObject({ + status: 'error', + code: 'vendor_error', + }) + }) +}) diff --git a/scripts/__tests__/check-auth-npm-changeset.test.mjs b/scripts/__tests__/check-auth-npm-changeset.test.mjs index 4554edd517..cb5e259f50 100644 --- a/scripts/__tests__/check-auth-npm-changeset.test.mjs +++ b/scripts/__tests__/check-auth-npm-changeset.test.mjs @@ -243,10 +243,20 @@ describe('require-auth-npm-changeset.yml', () => { const filters = workflow?.on?.pull_request?.paths ?? [] - it('diffs the crate and both binding folders', () => { + it('diffs the crates the binding compiles in and both binding folders', () => { expect(shipped).toEqual([ 'packages/stack-auth/Cargo.toml', 'packages/stack-auth/src', + 'packages/stack-kms-vendor/Cargo.toml', + 'packages/stack-kms-vendor/src', + 'packages/stack-kms-aws/Cargo.toml', + 'packages/stack-kms-aws/src', + 'packages/stack-kms-vault/Cargo.toml', + 'packages/stack-kms-vault/src', + 'packages/stack-kms-gcp/Cargo.toml', + 'packages/stack-kms-gcp/src', + 'packages/stack-kms-azure/Cargo.toml', + 'packages/stack-kms-azure/src', 'languages/typescript/packages/auth', 'languages/typescript/packages/stack-auth-wasm', ]) diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index f45c156bf9..a58eff8fe6 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -810,6 +810,80 @@ Things to know: Flags: `--name `, `--write [path]`, `--json`. +### Key vendor backends + +**ZeroKMS users never need these commands.** Skip this section unless the +project keeps a keyset in a key vendor backend instead of ZeroKMS: AWS KMS, +Google Cloud KMS, Azure Key Vault, HashiCorp Vault Transit, or the static key +backend. Today only the Rust and Go SDKs load such keysets. + +#### `keyset create` + +```bash +stash keyset create --backend aws --vendor-key arn:aws:kms:us-east-1:111122223333:key/ --name users +stash keyset create --backend vault --vendor-key users --out keyset.env +stash keyset create --backend static --vendor-key dev-key --out .env.keyset.local +``` + +Generates a keyset id and a root key, wraps the root key under `--vendor-key` +(one vendor call), and prints a **keyset record** as a dotenv line: + +```dotenv +CS_KEYSET_RECORD_USERS=stack-keyset:… +``` + +- `--backend ` and `--vendor-key ` are + required. `--mode` is optional: `derived` is the default and the only key + mode in this release. + `--name ` labels the keyset and names the variable + (`CS_KEYSET_RECORD_`, else `CS_KEYSET_RECORD`). + The output names the backend in full (`aws-kms`, `gcp-kms`, + `azure-key-vault`, `vault-transit`, `static`), and `--backend` takes those + full names too. +- **The record is not secret.** Only the vendor key can unwrap its key. Keep it + in config or an env var. **No SDK reads `CS_KEYSET_RECORD*` by itself**: the + app passes the value to `encrypt.WithKeysetRecords` (Go) or + `KeysetRecord::parse` (Rust). +- **Credentials come from the vendor's own environment chain, never from a + flag** — the same chain the Rust and Go SDKs use. AWS: `AWS_REGION` plus + static keys, web identity, ECS or IMDS (no `~/.aws` profiles; export them + first with `aws configure export-credentials --format env`). Google Cloud: + `GOOGLE_OAUTH_ACCESS_TOKEN` (for example `gcloud auth print-access-token`), + else the metadata server; service account key files are **not** read. Azure: + `AZURE_KEY_VAULT_ACCESS_TOKEN`, client secret, workload identity or managed + identity. Vault: `VAULT_ADDR` with `VAULT_TOKEN`, AppRole or Kubernetes auth. + Do not read or print these values; ask the user to set them. +- **Static backend.** `--key-env ` names the variable + that holds the 32-byte key (base64 or hex) — the name, never the key. Without + it a new key is generated and printed as `CS_STATIC_KEY`, which **is a + secret and the only copy**: write it with `--out` and move it to a secret + store. Never commit it. +- `--out ` writes the block with mode 0600 and refuses an existing file + unless `--force`. Stdout carries only the block (or the `--json` event). + With `--json`, `--out` gives `{ status: "written", … }`, which never carries + the static key; failures are `{ status: "error", code, message, hint? }`. + +#### `keyset rewrap` + +```bash +stash keyset rewrap --in keyset.env --out keyset.env --force +stash keyset rewrap stack-keyset:… --vendor-key arn:aws:kms:us-east-1:111122223333:key/ +stash keyset rewrap --in .env.keyset.local --key-env CS_STATIC_KEY --vendor-key dev-key-2 --new-key-env CS_STATIC_KEY_2 +``` + +Unwraps the record's key and wraps it again (two vendor calls). With no +`--vendor-key` it uses the record's own key, which moves the record to that +key's current version after a rotation; with `--vendor-key` it moves to another +key of the same backend. The keyset id, name and mode do not change, and +**stored data is not re-keyed**. Every data key comes from the record's root +key, so a record may move to any key of its backend and every stored value +still opens. Replace the old record in every app afterwards. The record is the argument or `--in ` (one +record per file). A static record needs `--key-env` for its current key, and +`--new-key-env` (or a generated key) when `--vendor-key` names another key. + +Flags: `--in `, `--vendor-key `, `--key-env `, +`--new-key-env `, `--out `, `--force`, `--json`. + ## Programmatic API ```typescript diff --git a/skills/stash-zerokms/SKILL.md b/skills/stash-zerokms/SKILL.md index dfee7d5f6d..4ca0a2c795 100644 --- a/skills/stash-zerokms/SKILL.md +++ b/skills/stash-zerokms/SKILL.md @@ -215,6 +215,20 @@ const client = await Encryption({ why CLI operations (`stash encrypt backfill`, dev-time tooling) work without any keyset configuration. +## Key vendor backends (keysets outside ZeroKMS) + +A keyset can also live in a **key vendor backend** instead of ZeroKMS: AWS +KMS, Google Cloud KMS, Azure Key Vault, HashiCorp Vault Transit, or a static +key. The customer then holds a **keyset record** (a `stack-keyset:…` string), +and a key in their own KMS protects it. Clients, grants, lock context and +decryption policies are ZeroKMS features: a vendor keyset has none of them, and +it refuses a lock context with an error. Only the Rust crates and the Go SDK +support key vendor backends today. `@cipherstash/stack` (TypeScript) does not +support them yet, so a TypeScript app keeps its keysets in ZeroKMS. `stash +keyset create` makes a keyset record and `stash keyset rewrap` moves one to +another vendor key of the same backend (see `stash-cli`). The vendor sees and +can refuse one unwrap of the keyset's root key per cache TTL, not each value. + ## Two gates, two very different failures Keyset access and lock context are **independent gates**, and their failures