From 7d4722cf57ae8b587eb5054c8638b91577ac45c8 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Thu, 12 Nov 2026 16:23:51 +1100 Subject: [PATCH] feat(stack-kms-vendor): keyset records, derived data keys and the static key backend (CIP-5306) The vendor seam (wrap, unwrap), keyset records, the derived data key mode with the root key TTL in the key source, the key router (ZeroKMS and at most one vendor, by keyset id), the static key backend, the transport seam, the shared credential cache, the contract suite and the fixture harness. The vendor and mode bytes are bound into every derived key. --- .github/workflows/tests-crates.yml | 33 +- .github/workflows/udeps.yml | 2 + AGENTS.md | 5 +- Cargo.lock | 35 + Cargo.toml | 1 + SECURITY.md | 7 +- mise.toml | 6 + packages/stack-kms-vendor/Cargo.toml | 76 ++ packages/stack-kms-vendor/LICENSE | 96 +++ packages/stack-kms-vendor/src/bound.rs | 150 ++++ packages/stack-kms-vendor/src/contract.rs | 485 +++++++++++++ packages/stack-kms-vendor/src/credential.rs | 680 ++++++++++++++++++ packages/stack-kms-vendor/src/derive.rs | 351 +++++++++ packages/stack-kms-vendor/src/error.rs | 140 ++++ packages/stack-kms-vendor/src/fake.rs | 154 ++++ packages/stack-kms-vendor/src/http.rs | 239 ++++++ packages/stack-kms-vendor/src/lib.rs | 260 +++++++ packages/stack-kms-vendor/src/record.rs | 502 +++++++++++++ packages/stack-kms-vendor/src/replay.rs | 272 +++++++ packages/stack-kms-vendor/src/retry.rs | 187 +++++ packages/stack-kms-vendor/src/router.rs | 209 ++++++ packages/stack-kms-vendor/src/source.rs | 369 ++++++++++ packages/stack-kms-vendor/src/static_key.rs | 216 ++++++ packages/stack-kms-vendor/src/transport.rs | 256 +++++++ packages/stack-kms-vendor/src/vendor.rs | 105 +++ packages/stack-kms-vendor/tasks.toml | 7 + packages/stack-kms-vendor/tests/contract.rs | 23 + packages/stack-kms-vendor/tests/router.rs | 346 +++++++++ release-plz.toml | 17 + .../__tests__/cargo-publish-opt-out.test.mjs | 6 +- scripts/__tests__/crates-ci.test.mjs | 38 +- .../release-plz-root-config.test.mjs | 27 +- .../workflow-dispatch-job-conditions.test.mjs | 4 +- 33 files changed, 5287 insertions(+), 17 deletions(-) create mode 100644 packages/stack-kms-vendor/Cargo.toml create mode 100644 packages/stack-kms-vendor/LICENSE create mode 100644 packages/stack-kms-vendor/src/bound.rs create mode 100644 packages/stack-kms-vendor/src/contract.rs create mode 100644 packages/stack-kms-vendor/src/credential.rs create mode 100644 packages/stack-kms-vendor/src/derive.rs create mode 100644 packages/stack-kms-vendor/src/error.rs create mode 100644 packages/stack-kms-vendor/src/fake.rs create mode 100644 packages/stack-kms-vendor/src/http.rs create mode 100644 packages/stack-kms-vendor/src/lib.rs create mode 100644 packages/stack-kms-vendor/src/record.rs create mode 100644 packages/stack-kms-vendor/src/replay.rs create mode 100644 packages/stack-kms-vendor/src/retry.rs create mode 100644 packages/stack-kms-vendor/src/router.rs create mode 100644 packages/stack-kms-vendor/src/source.rs create mode 100644 packages/stack-kms-vendor/src/static_key.rs create mode 100644 packages/stack-kms-vendor/src/transport.rs create mode 100644 packages/stack-kms-vendor/src/vendor.rs create mode 100644 packages/stack-kms-vendor/tasks.toml create mode 100644 packages/stack-kms-vendor/tests/contract.rs create mode 100644 packages/stack-kms-vendor/tests/router.rs diff --git a/.github/workflows/tests-crates.yml b/.github/workflows/tests-crates.yml index 425dca68cb..6b53ba9af3 100644 --- a/.github/workflows/tests-crates.yml +++ b/.github/workflows/tests-crates.yml @@ -20,6 +20,7 @@ on: - packages/stack-auth/** - packages/stack-profile/** - packages/stack-kms/** + - packages/stack-kms-vendor/** - packages/stack-encrypt/** - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** @@ -44,6 +45,7 @@ on: - packages/stack-auth/** - packages/stack-profile/** - packages/stack-kms/** + - packages/stack-kms-vendor/** - packages/stack-encrypt/** - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** @@ -151,12 +153,39 @@ jobs: # with no `version`, or a crate that uses API its dependency's packaged # source does not have yet. stack-profile and stack-auth are in the list # because stack-kms and stack-encrypt depend on them: without them, cargo - # resolves the published versions, which lag the tree. No token. + # resolves the published versions, which lag the tree. stack-kms-vendor + # is publishable but staged in release-plz.toml (its + # first version is published by hand); packaging it here keeps it + # publishable meanwhile. No token. # `--allow-dirty` tolerates files earlier steps leave in the tree. Once a # version is on crates.io, cargo warns rather than fails on a dry run of # it. - name: Verify the stack-* crates package cleanly for crates.io - run: cargo publish --locked --dry-run --allow-dirty -p stack-profile -p stack-auth -p stack-kms -p stack-encrypt-derive -p stack-encrypt + run: cargo publish --locked --dry-run --allow-dirty -p stack-profile -p stack-auth -p stack-kms -p stack-encrypt-derive -p stack-encrypt -p stack-kms-vendor + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + cache: true + # Must name runs-on's image; see scripts/__tests__/workflow-mise-cache-image.test.mjs + cache_key_prefix: mise-v1-ubuntu-2204 + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + cache: true + # Must name runs-on's image; see scripts/__tests__/workflow-mise-cache-image.test.mjs + cache_key_prefix: mise-v1-ubuntu-2204 + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + node-bindings: name: node bindings, napi typings, stack-auth-wasm diff --git a/.github/workflows/udeps.yml b/.github/workflows/udeps.yml index db3d33b98c..1e51657ee5 100644 --- a/.github/workflows/udeps.yml +++ b/.github/workflows/udeps.yml @@ -14,6 +14,7 @@ on: - packages/stack-auth/** - packages/stack-profile/** - packages/stack-kms/** + - packages/stack-kms-vendor/** - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** @@ -33,6 +34,7 @@ on: - packages/stack-auth/** - packages/stack-profile/** - packages/stack-kms/** + - packages/stack-kms-vendor/** - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** diff --git a/AGENTS.md b/AGENTS.md index 20a056d9e9..ec5ecb4161 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,7 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l `cipherstash/encrypt-query-language` repository. Old upstream issue and PR 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`: The key vendor seam (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. It is a root workspace member, publishable in the `stack-kms-vendor` version group, and staged in `release-plz.toml` (`release = false`) until its 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/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) @@ -715,7 +716,7 @@ term. `cts-common` and `zerokms-protocol` take caret requirements, because the published `stack-auth` inherits them, and an exact pin would stop the suite sharing one `cts-common` with it. -- **Three Cargo workspaces, not one.** The root workspace (the six stack-* +- **Three Cargo workspaces, not one.** The root workspace (the seven stack-* crates and the three node binding crates), protect-ffi's and EQL's. The root `Cargo.toml` excludes the other two, and they pin their own vitaminc. The three fuzz crates, the two Go guests and the EQL v4 block ORE timing @@ -757,7 +758,7 @@ exact pin would stop the suite sharing one `cts-common` with it. `MISE_ENV=test` cargo tools — and the live tests), `crap-crates.yml`, `mutants.yml`, `fuzz.yml`, `miri.yml` and `udeps.yml`. `scripts/__tests__/crates-ci.test.mjs` fails when a mise task in the root - `mise.toml` or the five crate `tasks.toml` files loses its last CI caller. + `mise.toml` or the six crate `tasks.toml` files loses its last CI caller. - **Auth refresh cancellation soak:** `tests-auth-refresh.yml` runs a short, seeded, virtual-time soak on every PR and the long run on auth changes and Version Packages PRs. `release-plz.yml` also requires the long run before diff --git a/Cargo.lock b/Cargo.lock index c452341fd1..c96f3525c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,6 +771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1400,6 +1401,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.13.0" @@ -3509,6 +3519,31 @@ dependencies = [ "zerokms-protocol", ] +[[package]] +name = "stack-kms-vendor" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "base64ct", + "futures", + "hkdf", + "miette", + "reqwest", + "serde", + "serde_json", + "sha2 0.11.0", + "stack-auth", + "stack-encrypt", + "stack-kms", + "subtle", + "thiserror 1.0.69", + "tokio", + "uuid", + "vitaminc 0.9.0", + "web-time", + "zeroize", +] + [[package]] name = "stack-profile" version = "0.44.0" diff --git a/Cargo.toml b/Cargo.toml index 7dec619632..c75cfe2a57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "packages/stack-auth", "packages/stack-profile", "packages/stack-kms", + "packages/stack-kms-vendor", "packages/stack-encrypt", "packages/stack-encrypt-derive", "packages/stack-guest-abi", diff --git a/SECURITY.md b/SECURITY.md index 8c44a1d228..b71c1aa05f 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. | | `@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 | @@ -38,6 +38,11 @@ from the stack-* crates), released by `.github/workflows/release-golang.yml` as `languages/golang/vX.Y.Z` tags. All of these are in scope for security reports on the same terms as the npm packages above. +One more Rust crate, **`stack-kms-vendor`** +(`packages/stack-kms-vendor`), is prepared for +crates.io but not yet published. It is in scope on the same terms, and +under the same licence as the stack-* crates below. + The five stack-* crates are licensed under the PolyForm Internal Use License 1.0.0 (each crate's `LICENSE`), not the repository's MIT licence: they may be used for internal business operations only, and not redistributed. That diff --git a/mise.toml b/mise.toml index c0a664fa8c..17ba60f03a 100644 --- a/mise.toml +++ b/mise.toml @@ -10,6 +10,7 @@ includes = [ "packages/stack-auth/tasks.toml", "packages/stack-kms/tasks.toml", + "packages/stack-kms-vendor/tasks.toml", "packages/stack-profile/tasks.toml", "packages/stack-encrypt/tasks.toml", "packages/stack-guest-abi/tasks.toml", @@ -119,6 +120,11 @@ check() { check stack-auth --no-default-features check stack-kms --no-default-features check stack-encrypt --no-default-features +# The key vendor seam (ADR-0014): no transport of its own, so it must build for WASI as is. Its +# `http` feature is the native transport, gated to non-wasm32 targets, so it +# must add nothing here either. +check stack-kms-vendor +check stack-kms-vendor --features http # The Go binding's credential guest (ADR-0005) compiles stack-profile for # WASI: the hostname and the process id are native-only there. check stack-profile diff --git a/packages/stack-kms-vendor/Cargo.toml b/packages/stack-kms-vendor/Cargo.toml new file mode 100644 index 0000000000..1dba1f7e99 --- /dev/null +++ b/packages/stack-kms-vendor/Cargo.toml @@ -0,0 +1,76 @@ +[package] +name = "stack-kms-vendor" +description = "Key vendor backends for Stack Encrypt: keyset records, derived data keys, and the static key backend" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +license-file = "LICENSE" +# Publishable, in the `stack-kms-vendor` version group with every vendor crate +# (stack-kms-aws, ...). release-plz.toml keeps `release = false` until the +# first version is on crates.io: crates.io Trusted Publishing needs an +# existing crate, so the first version is published by hand (see +# docs/plans/2026-11-05-key-vendor-backends-decisions.md, CIP-5307). + +[features] +# `stack_kms_vendor::contract` (the shared contract suite every key vendor +# backend runs), `FakeVendor` (an in-memory key vendor backend) and +# `stack_kms_vendor::replay` (the recorded-fixture harness every vendor +# crate's tests use). The contract suite drives a real `StackCipher`, so this +# pulls stack-encrypt in; the fixtures are serde types, so it pulls serde in. +test-support = ["dep:stack-encrypt", "dep:serde", "stack-kms/test-support"] +# `HttpVendorTransport`: the native `VendorTransport`, over the reqwest client +# and the TLS configuration stack-kms uses for ZeroKMS. Native only: on wasm32 +# the dependencies are not in the graph and the type does not exist, because +# the WASI guest sends through its `transport_send` host import instead. +http = ["dep:reqwest", "dep:stack-auth", "dep:tokio"] + +[dependencies] +stack-kms = { path = "../stack-kms", version = "0.2.0", default-features = false } +# The contract suite only: it seals and opens values through a cipher. +stack-encrypt = { path = "../stack-encrypt", version = "0.3.0", default-features = false, optional = true } + +vitaminc = { workspace = true, features = ["protected", "random"] } +aes-gcm = { version = "0.10.3", features = ["zeroize"] } +base64ct = { version = "1.7", features = ["alloc"] } +futures = "0.3.25" +hkdf = "0.13" +sha2 = "0.11" +subtle = "2.6" +miette = { workspace = true } +# The fixture types of `replay` only. +serde = { workspace = true, optional = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +web-time = { workspace = true } +zeroize = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +reqwest = { workspace = true, optional = true } +# Only for `stack_auth::tls::client_config`: the platform verifier with the +# compromised-CA blocklist that every bundled reqwest client in the stack +# crates uses. +stack-auth = { workspace = true, optional = true, features = ["http"] } +# The backoff timer of `HttpVendorTransport::sleep`. reqwest already runs on +# tokio, so this adds no runtime. +tokio = { version = "1.47.1", optional = true, default-features = false, features = ["time"] } + +[dev-dependencies] +stack-kms = { path = "../stack-kms", default-features = false, features = ["test-support"] } +stack-encrypt = { path = "../stack-encrypt", default-features = false } +tokio = { workspace = true, features = ["rt", "macros", "time", "net", "io-util"] } + +[package.metadata.docs.rs] +all-features = true + +[[test]] +name = "contract" +required-features = ["test-support"] + +[[test]] +name = "router" +required-features = ["test-support"] diff --git a/packages/stack-kms-vendor/LICENSE b/packages/stack-kms-vendor/LICENSE new file mode 100644 index 0000000000..2cbd67a66d --- /dev/null +++ b/packages/stack-kms-vendor/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software +to do everything you might do with the software that would +otherwise infringe the licensor's copyright in it for any +permitted purpose. However, you may only make changes or +new works based on the software according to [Changes and New +Works License](#changes-and-new-works-license), and you may +not distribute the software. + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## Internal Business Use + +Use of the software for the internal business operations of +you and your company is use for a permitted purpose. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/packages/stack-kms-vendor/src/bound.rs b/packages/stack-kms-vendor/src/bound.rs new file mode 100644 index 0000000000..b94635231a --- /dev/null +++ b/packages/stack-kms-vendor/src/bound.rs @@ -0,0 +1,150 @@ +//! Associated data bound by us, for a vendor key that cannot bind it. +//! +//! [`KeyVendorBackend`](crate::KeyVendorBackend) requires that `unwrap` +//! fails unless its `aad` is the `aad` given to `wrap`. Some vendor keys +//! have no associated data of their own: an Azure Key Vault RSA key, or a +//! HashiCorp Vault Transit key that is not derived (Vault ignores +//! `context` on such a key, and older Vault versions ignore +//! `associated_data`). For these, the vendor encrypts a **bound key** +//! instead of the bare key: +//! +//! ```text +//! bound key = version(1) = 1 ‖ SHA-256(frame[BOUND_KEY_LABEL, aad]) (32) ‖ key (32) +//! ``` +//! +//! On unwrap, [`open_bound_key`] recomputes the digest from the `aad` it is +//! given and compares it in constant time. A wrapped key moved to another +//! keyset, value or field decrypts, but does not open. +//! +//! # The rule for the vendor +//! +//! This is sound only if the vendor's encryption is **non-malleable**: an +//! attacker who holds a wrapped key must not be able to change its digest +//! without the vendor key. An AEAD (AES-GCM, ChaCha20-Poly1305) and RSA-OAEP +//! are non-malleable. Unauthenticated modes (AES-CBC, AES-CTR, RSA PKCS#1 +//! v1.5) are not, and must not be used. +//! +//! The bound key is 65 bytes, small enough for RSA-OAEP-256 with a 2048-bit +//! key (190 bytes at most). + +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; +use vitaminc::protected::{Controlled, Protected}; +use zeroize::Zeroizing; + +use crate::derive::frame; +use crate::vendor::KeyBytes; + +/// The label of the associated-data digest in a bound key. +pub const BOUND_KEY_LABEL: &[u8] = b"stack-kms-vendor/bound-key/v1"; +/// The version byte of a bound key. +pub const BOUND_KEY_VERSION: u8 = 1; +/// The length of a bound key: version, digest, key. +pub const BOUND_KEY_LEN: usize = 1 + 32 + 32; + +/// A bound key did not open. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum BoundKeyError { + /// The bytes are not a bound key: wrong length or version. + #[error("the vendor plaintext is not a bound key")] + Malformed, + /// The key was wrapped with other associated data. + #[error("the wrapped key belongs to other associated data")] + WrongAad, +} + +/// The digest of `aad` that a bound key carries. +fn aad_digest(aad: &[u8]) -> [u8; 32] { + Sha256::digest(frame(&[BOUND_KEY_LABEL, aad])).into() +} + +/// The bound key of `key` under `aad`: the plaintext a vendor encrypts in +/// place of `key`. Wiped on drop. +pub fn bind_key(key: &KeyBytes, aad: &[u8]) -> Zeroizing> { + let mut out = Zeroizing::new(Vec::with_capacity(BOUND_KEY_LEN)); + out.push(BOUND_KEY_VERSION); + out.extend_from_slice(&aad_digest(aad)); + out.extend_from_slice(key.risky_ref()); + out +} + +/// The key in `bound`, if `bound` is a bound key made under `aad`. +pub fn open_bound_key(bound: &[u8], aad: &[u8]) -> Result { + if bound.len() != BOUND_KEY_LEN || bound[0] != BOUND_KEY_VERSION { + return Err(BoundKeyError::Malformed); + } + let (digest, key) = bound[1..].split_at(32); + if !bool::from(digest.ct_eq(&aad_digest(aad))) { + return Err(BoundKeyError::WrongAad); + } + let mut out = Zeroizing::new([0u8; 32]); + out.copy_from_slice(key); + Ok(Protected::new(*out)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + #[test] + fn a_bound_key_opens_under_its_own_aad_only() { + let key = Protected::new([5u8; 32]); + let bound = bind_key(&key, b"aad"); + assert_eq!(bound.len(), BOUND_KEY_LEN); + assert_eq!( + open_bound_key(&bound, b"aad").unwrap().risky_ref(), + &[5u8; 32] + ); + assert_eq!( + open_bound_key(&bound, b"other").unwrap_err(), + BoundKeyError::WrongAad + ); + assert_eq!( + open_bound_key(&bound, b"").unwrap_err(), + BoundKeyError::WrongAad + ); + } + + #[test] + fn anything_but_a_bound_key_is_malformed() { + let bound = bind_key(&Protected::new([5u8; 32]), b"aad"); + assert_eq!( + open_bound_key(&bound[..64], b"aad").unwrap_err(), + BoundKeyError::Malformed + ); + let mut long = bound.to_vec(); + long.push(0); + assert_eq!( + open_bound_key(&long, b"aad").unwrap_err(), + BoundKeyError::Malformed + ); + let mut version = bound.to_vec(); + version[0] = 2; + assert_eq!( + open_bound_key(&version, b"aad").unwrap_err(), + BoundKeyError::Malformed + ); + assert_eq!( + open_bound_key(&[5u8; 32], b"aad").unwrap_err(), + BoundKeyError::Malformed + ); + } + + /// A storage commitment: a wrapped key bound today must open later. + #[test] + fn the_bound_key_is_pinned() { + let bound = bind_key(&Protected::new([5u8; 32]), b"aad"); + assert_eq!( + hex(&bound), + concat!( + "01", + "ae51a3bebe8d3505fbec2c4045a899e0ffc51efd1fa0b91d1ce298ceb042e693", + "0505050505050505050505050505050505050505050505050505050505050505" + ) + ); + } +} diff --git a/packages/stack-kms-vendor/src/contract.rs b/packages/stack-kms-vendor/src/contract.rs new file mode 100644 index 0000000000..f0df7c7419 --- /dev/null +++ b/packages/stack-kms-vendor/src/contract.rs @@ -0,0 +1,485 @@ +//! The shared contract suite every key vendor backend runs. +//! +//! A vendor crate calls [`run`] (or each check alone) from its own tests +//! with a factory for its backend and a vendor key reference it may use. +//! Every check drives the backend through [`VendorKeySource`] and a real +//! [`StackCipher`], so it tests what a customer gets, not the trait alone. +//! A check panics with a message that names the broken rule. +//! +//! ```ignore +//! #[tokio::test] +//! async fn aws_kms_meets_the_contract() { +//! stack_kms_vendor::contract::run(|| AwsKms::new(localstack()), KEY_ARN).await; +//! } +//! ``` +//! +//! The checks: +//! +//! * a value and an index term round-trip, as a v1 leaf with an empty tag, +//! and a second cipher derives the same term; +//! * a value opened under another context (descriptor) is refused; +//! * a leaf of another key backend does not open, and makes no vendor +//! call; +//! * ZeroKMS-only inputs are refused, never ignored; +//! * the root key is unwrapped once per TTL; +//! * a batch keeps its order; +//! * a throttled call is retried, and a call still throttled after the last +//! attempt fails. + +// A contract check is a test: it panics with the rule it found broken. +#![allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] + +use std::borrow::Cow; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use stack_encrypt::kms::{ + BackendErrorKind, Context, DataKeySource, DecryptionPolicy, Error as KmsError, + FakeDataKeySource, GenerateKeyPayload, KeyMode, KeyVendor, RetrieveKeyPayload, + UnverifiedContext, ZeroKmsFeature, +}; +use stack_encrypt::{nonempty, CipherText, Error, SealedValue, StackCipher, StackCipherText}; + +use crate::{KeyBytes, KeyVendorBackend, KeysetRecord, RetryOptions, VendorKeySource}; + +/// The one key mode this release offers. +const MODE: KeyMode = KeyMode::Derived; + +/// Run every check of the contract against the backends `make` builds. +/// `key_ref` names a vendor key the backend may wrap and unwrap under. +pub async fn run(make: F, key_ref: &str) +where + B: KeyVendorBackend + 'static, + F: Fn() -> B, +{ + round_trip(&make, key_ref).await; + refuses_another_context(make(), key_ref).await; + refuses_another_key_backend(make(), key_ref).await; + refuses_zerokms_features(make(), key_ref).await; + obeys_the_root_key_ttl(&make, key_ref).await; + keeps_batch_order(make(), key_ref).await; + backs_off_when_throttled(&make, key_ref).await; +} + +/// A key vendor backend that counts the calls it forwards, and can refuse +/// the next calls as throttled. +pub struct Observed { + inner: B, + wraps: Arc, + unwraps: Arc, + throttle_next: Arc, +} + +/// The counters and the throttle of an [`Observed`] backend, kept by the +/// test after the backend moves into a key source. +#[derive(Clone)] +pub struct Observer { + wraps: Arc, + unwraps: Arc, + throttle_next: Arc, +} + +impl Observer { + /// Wrap calls forwarded so far. + pub fn wraps(&self) -> usize { + self.wraps.load(Ordering::SeqCst) + } + + /// Unwrap calls forwarded so far. + pub fn unwraps(&self) -> usize { + self.unwraps.load(Ordering::SeqCst) + } + + /// Refuse the next `count` calls as throttled, without forwarding them. + pub fn throttle_next(&self, count: usize) { + self.throttle_next.store(count, Ordering::SeqCst); + } +} + +impl Observed { + /// Observe `inner`. + pub fn new(inner: B) -> (Self, Observer) { + let observer = Observer { + wraps: Arc::default(), + unwraps: Arc::default(), + throttle_next: Arc::default(), + }; + ( + Self { + inner, + wraps: Arc::clone(&observer.wraps), + unwraps: Arc::clone(&observer.unwraps), + throttle_next: Arc::clone(&observer.throttle_next), + }, + observer, + ) + } + + fn throttle(&self) -> Result<(), KmsError> { + let pending = self + .throttle_next + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1)); + match pending { + Ok(_) => Err(KmsError::Backend { + backend: self.inner.vendor().into(), + kind: BackendErrorKind::Throttled, + source: "throttled by the contract suite".into(), + }), + Err(_) => Ok(()), + } + } +} + +impl KeyVendorBackend for Observed { + fn vendor(&self) -> KeyVendor { + self.inner.vendor() + } + + async fn wrap(&self, key_ref: &str, key: &KeyBytes, aad: &[u8]) -> Result, KmsError> { + self.throttle()?; + let _ = self.wraps.fetch_add(1, Ordering::SeqCst); + self.inner.wrap(key_ref, key, aad).await + } + + async fn unwrap( + &self, + key_ref: &str, + wrapped: &[u8], + aad: &[u8], + ) -> Result { + self.throttle()?; + let _ = self.unwraps.fetch_add(1, Ordering::SeqCst); + self.inner.unwrap(key_ref, wrapped, aad).await + } + + async fn sleep(&self, duration: Duration) { + self.inner.sleep(duration).await; + } +} + +/// A cipher over one fresh keyset of `backend`, with its record. +async fn cipher_over( + backend: B, + key_ref: &str, + configure: impl FnOnce(VendorKeySource>) -> VendorKeySource>, +) -> ( + StackCipher>>, + KeysetRecord, + Observer, +) { + let (backend, observer) = Observed::new(backend); + let record = KeysetRecord::create(&backend, key_ref, MODE, Some("contract".into())) + .await + .unwrap_or_else(|error| panic!("creating a keyset record failed: {error}")); + let source = configure( + VendorKeySource::new(backend, [record.clone()]) + .unwrap_or_else(|error| panic!("loading the record failed: {error}")), + ); + let cipher = StackCipher::builder() + .kms(source) + .init() + .await + .unwrap_or_else(|error| panic!("a cipher over a vendor keyset failed to init: {error}")); + (cipher, record, observer) +} + +fn single(ciphertext: &StackCipherText) -> &SealedValue { + match ciphertext { + CipherText::Single(leaf) => leaf, + _ => panic!("a string seals as one leaf"), + } +} + +/// A value and an index term round-trip, the leaf is the v1 leaf with an +/// empty tag, and a second cipher over the same record (a backend from +/// `make`) derives the same term and opens the same value. +pub async fn round_trip(make: &F, key_ref: &str) +where + B: KeyVendorBackend, + F: Fn() -> B, +{ + let (cipher, record, _) = cipher_over(make(), key_ref, |s| s).await; + let keyset = cipher.default_keyset(); + assert_eq!( + keyset.keyset_id(), + record.id(), + "the only record is the default keyset" + ); + let context = nonempty!("users").with("email"); + + let sealed = keyset + .encrypt_tree("alice@example.com".to_string(), context) + .await + .unwrap_or_else(|error| panic!("sealing failed: {error}")); + let leaf = single(&sealed); + let bytes = leaf.to_bytes(); + assert_eq!( + bytes.first(), + Some(&SealedValue::FORMAT_VERSION), + "a key vendor backend seals the v1 leaf" + ); + assert_eq!(leaf.keyset_id(), record.id()); + assert!(leaf.tag().is_empty(), "a derived data key has no tag"); + let reread = SealedValue::from_bytes(&bytes).expect("the leaf decodes"); + let opened: String = cipher + .decrypt(CipherText::Single(reread), context) + .await + .unwrap_or_else(|error| panic!("opening failed: {error}")); + assert_eq!(opened, "alice@example.com"); + + let term = keyset + .equality_term("alice@example.com", context) + .await + .unwrap_or_else(|error| panic!("an equality term failed: {error}")); + let again = keyset + .equality_term("alice@example.com", context) + .await + .expect("a second term"); + assert_eq!(term.as_bytes(), again.as_bytes(), "terms are deterministic"); + let other = keyset + .equality_term("bob@example.com", context) + .await + .expect("a term for another value"); + assert_ne!(term.as_bytes(), other.as_bytes()); + + // Another process, with the same record, derives the same term and + // opens the same value. + let other = StackCipher::builder() + .kms(VendorKeySource::new(make(), [record.clone()]).expect("load the record again")) + .init() + .await + .expect("a second cipher"); + let term_elsewhere = other + .default_keyset() + .equality_term("alice@example.com", context) + .await + .expect("a term in the second cipher"); + assert_eq!( + term.as_bytes(), + term_elsewhere.as_bytes(), + "another cipher with the same record derives the same term" + ); + let opened: String = other + .decrypt(sealed, context) + .await + .unwrap_or_else(|error| panic!("another cipher must open the value: {error}")); + assert_eq!(opened, "alice@example.com"); + + let ore = keyset + .ore_term(42u64, context) + .await + .unwrap_or_else(|error| panic!("an ORE term failed: {error}")); + let ore_again = keyset.ore_term(42u64, context).await.expect("ORE again"); + assert_eq!(ore, ore_again); +} + +/// A value opened under another context is refused. +pub async fn refuses_another_context(backend: B, key_ref: &str) { + let (cipher, _, _) = cipher_over(backend, key_ref, |s| s).await; + let sealed = cipher + .default_keyset() + .encrypt_tree("secret".to_string(), nonempty!("users").with("email")) + .await + .expect("seal"); + let result = cipher + .decrypt::(sealed, nonempty!("users").with("phone")) + .await; + assert!( + result.is_err(), + "a value opened under another descriptor must be refused" + ); +} + +/// A leaf of another key backend does not open, and costs no vendor call. +/// +/// The leaf names its keyset and no backend. The ZeroKMS leaf's keyset is +/// in no record of this vendor, so the vendor answers "not found" before +/// any call. The vendor's leaf does not open under ZeroKMS either. +pub async fn refuses_another_key_backend(backend: B, key_ref: &str) { + let vendor = backend.vendor(); + let (cipher, _, observer) = cipher_over(backend, key_ref, |s| s).await; + let zerokms = StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("a ZeroKMS cipher"); + let sealed = zerokms + .default_keyset() + .encrypt_tree("zerokms".to_string(), b"ctx") + .await + .expect("seal under ZeroKMS"); + let before = observer.unwraps(); + match cipher.decrypt::(sealed, b"ctx").await { + Err(Error::Kms(KmsError::Backend { + backend, + kind: BackendErrorKind::NotFound, + .. + })) => assert_eq!(backend, vendor.into()), + other => panic!("a ZeroKMS leaf must not open, got {other:?}"), + } + assert_eq!(observer.unwraps(), before, "refused before any key call"); + + // And the other way: a ZeroKMS cipher does not open this vendor's leaf. + let sealed = cipher + .default_keyset() + .encrypt_tree("vendor".to_string(), b"ctx") + .await + .expect("seal under the vendor"); + assert!( + zerokms.decrypt::(sealed, b"ctx").await.is_err(), + "a vendor leaf must not open under ZeroKMS" + ); +} + +fn assert_zerokms_only(result: Result, feature: ZeroKmsFeature) { + match result { + Err(KmsError::ZeroKmsOnly { feature: found, .. }) => assert_eq!(found, feature), + other => panic!("expected a ZeroKmsOnly refusal of {feature}, got {other:?}"), + } +} + +/// ZeroKMS-only inputs are refused, never ignored. +pub async fn refuses_zerokms_features(backend: B, key_ref: &str) { + let (cipher, record, _) = cipher_over(backend, key_ref, |s| s).await; + let source = cipher.kms(); + let keyset = Some(record.id()); + let tag = [Context::Tag("tenant".into())]; + assert_zerokms_only( + source + .generate_keys( + vec![GenerateKeyPayload::new("d", Cow::Borrowed(&tag))], + keyset, + None, + ) + .await, + ZeroKmsFeature::Context, + ); + assert_zerokms_only( + source + .generate_keys( + vec![GenerateKeyPayload::new("d", Cow::Owned(vec![])) + .with_decryption_policy(DecryptionPolicy { conditions: vec![] })], + keyset, + None, + ) + .await, + ZeroKmsFeature::DecryptionPolicy, + ); + let mut claims = std::collections::HashMap::new(); + let _ = claims.insert("sub".to_string(), None); + let unverified = UnverifiedContext(claims); + assert_zerokms_only( + source + .retrieve_keys( + vec![RetrieveKeyPayload::new([0u8; 16], "d", b"")], + keyset, + Some(&unverified), + ) + .await, + ZeroKmsFeature::UnverifiedContext, + ); +} + +/// The root key is unwrapped once per TTL. The index key is loaded once, +/// when the cipher loads the keyset, and the cipher keeps it: a term costs +/// no vendor call, even with a TTL of zero. +pub async fn obeys_the_root_key_ttl(make: &F, key_ref: &str) +where + B: KeyVendorBackend, + F: Fn() -> B, +{ + let context = nonempty!("users").with("email"); + let (cipher, _, observer) = cipher_over(make(), key_ref, |s| s).await; + let keyset = cipher.default_keyset(); + for _ in 0..3 { + let _ = keyset.equality_term("v", context).await.expect("term"); + let _ = keyset + .encrypt_tree("v".to_string(), context) + .await + .expect("seal"); + } + assert_eq!( + observer.unwraps(), + 1, + "the root key is unwrapped once and kept for its TTL" + ); + + let (cipher, _, observer) = cipher_over(make(), key_ref, |source| { + source.with_root_key_ttl(Duration::ZERO) + }) + .await; + let keyset = cipher.default_keyset(); + assert_eq!(observer.unwraps(), 1, "init loads the index key"); + for settle in 1..=3 { + let _ = keyset + .encrypt_tree("v".to_string(), context) + .await + .expect("seal"); + assert_eq!( + observer.unwraps(), + 1 + settle, + "an expired root key is unwrapped again" + ); + } + let _ = keyset.equality_term("v", context).await.expect("term"); + assert_eq!( + observer.unwraps(), + 4, + "the cipher keeps the index key, so a term makes no vendor call" + ); +} + +/// A batch keeps its order. +pub async fn keeps_batch_order(backend: B, key_ref: &str) { + let (cipher, _, _) = cipher_over(backend, key_ref, |s| s).await; + let values: Vec = (0..25).map(|n| format!("value-{n}")).collect(); + let sealed = cipher + .default_keyset() + .encrypt_tree(values.clone(), b"batch") + .await + .unwrap_or_else(|error| panic!("sealing a batch failed: {error}")); + let opened: Vec = cipher + .decrypt(sealed, b"batch") + .await + .unwrap_or_else(|error| panic!("opening a batch failed: {error}")); + assert_eq!(opened, values, "a batch keeps its order"); +} + +/// A throttled call is retried; one throttled past the last attempt fails. +pub async fn backs_off_when_throttled(make: &F, key_ref: &str) +where + B: KeyVendorBackend, + F: Fn() -> B, +{ + let options = RetryOptions { + max_attempts: 3, + base_delay: Duration::from_millis(1), + ..RetryOptions::default() + }; + let (cipher, _, observer) = cipher_over(make(), key_ref, |source| { + source + .with_retry_options(options.clone()) + .with_root_key_ttl(Duration::ZERO) + }) + .await; + let keyset = cipher.default_keyset(); + // With a TTL of zero each seal unwraps the root key: one call. + observer.throttle_next(1); + let sealed = keyset + .encrypt_tree("v".to_string(), b"ctx") + .await + .unwrap_or_else(|error| panic!("a throttled call must be retried: {error}")); + let opened: String = cipher.decrypt(sealed, b"ctx").await.expect("open"); + assert_eq!(opened, "v"); + + observer.throttle_next(options.max_attempts as usize); + match keyset.encrypt_tree("v".to_string(), b"ctx").await { + Err(Error::Kms(KmsError::Backend { + kind: BackendErrorKind::Throttled, + .. + })) => {} + other => panic!("still throttled after the last attempt must fail, got {other:?}"), + } + observer.throttle_next(0); +} diff --git a/packages/stack-kms-vendor/src/credential.rs b/packages/stack-kms-vendor/src/credential.rs new file mode 100644 index 0000000000..c1b10e1c60 --- /dev/null +++ b/packages/stack-kms-vendor/src/credential.rs @@ -0,0 +1,680 @@ +//! What the vendor crates share about credentials: the access token, the +//! credential error, and the cache that keeps a credential until it is +//! near its expiry. +//! +//! Each vendor crate declares one marker type that implements +//! [`CredentialKind`]. The marker names the credential the crate caches, +//! what the cache is keyed by (a scope, or nothing) and the text that +//! starts each [`CredentialError`]. The crate then implements +//! [`FetchCredential`] on the marker for each of its providers, and its own +//! provider trait for [`CachedToken`](CachedToken). The crate +//! names these with type aliases, for example +//! `type CachedToken

= stack_kms_vendor::CachedToken`. + +use std::borrow::Borrow; +use std::collections::HashMap; +use std::fmt; +use std::future::Future; +use std::hash::Hash; +use std::marker::PhantomData; +use std::sync::{Mutex as SyncMutex, PoisonError}; +use std::time::Duration; + +use futures::lock::Mutex; +use stack_kms::{BackendErrorKind, MaybeSend}; +use web_time::SystemTime; +use zeroize::Zeroizing; + +/// The time source of a credential cache: `SystemTime::now` unless a test +/// sets another. +pub type Clock = std::sync::Arc SystemTime + Send + Sync>; + +/// The system clock. +pub fn system_clock() -> Clock { + std::sync::Arc::new(SystemTime::now) +} + +/// When a credential stops working. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Expiry { + /// No known expiry: the credential is used until the vendor refuses it. + Never, + /// It lasts this long from when it was got (an OAuth `expires_in`, a + /// Vault lease). + After(Duration), + /// It stops working at this time (AWS temporary credentials). + At(SystemTime), +} + +/// A credential that [`CachedToken`] can keep. +pub trait Expiring: Clone + Send + Sync + 'static { + /// When the credential stops working. + fn expiry(&self) -> Expiry; +} + +/// An OAuth 2.0 bearer token, and how long it lasts from when it was +/// issued. Google Cloud and Azure use it. +/// +/// The token is wiped on drop and never printed. +#[derive(Clone)] +pub struct AccessToken { + token: Zeroizing, + expires_in: Option, +} + +impl fmt::Debug for AccessToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AccessToken") + .field("token", &"") + .field("expires_in", &self.expires_in) + .finish() + } +} + +impl AccessToken { + /// A token with no known expiry: it is used until the vendor refuses + /// it. + pub fn new(token: impl Into) -> Self { + Self { + token: Zeroizing::new(token.into()), + expires_in: None, + } + } + + /// The same token, which expires `expires_in` from now. + pub fn with_expires_in(mut self, expires_in: Duration) -> Self { + self.expires_in = Some(expires_in); + self + } + + /// The token. + pub fn token(&self) -> &str { + &self.token + } + + /// How long the token lasts from when it was issued. `None` for a token + /// with no known expiry. + pub fn expires_in(&self) -> Option { + self.expires_in + } +} + +impl Expiring for AccessToken { + fn expiry(&self) -> Expiry { + self.expires_in.map_or(Expiry::Never, Expiry::After) + } +} + +/// The credential of a vendor crate, as its cache and its errors see it. +/// A vendor crate implements this on one marker type (an empty enum). +pub trait CredentialKind: Sized + 'static { + /// The text that starts each [`CredentialError`] of this kind, for + /// example `"Google credentials"`. + const LABEL: &'static str; + + /// What the cache keeps one credential for: `()` for one credential, a + /// scope for one credential per scope. + type Key: Eq + Hash + Clone + Send + Sync + 'static; + + /// The credential. + type Credential: Expiring; +} + +/// A credential could not be got. +/// +/// `K` names the vendor: the message starts with [`CredentialKind::LABEL`]. +/// Each vendor crate names this type with an alias (`AuthError`, +/// `CredentialsError`). +pub struct CredentialError { + kind: BackendErrorKind, + message: String, + vendor: PhantomData K>, +} + +impl CredentialError { + /// A failure of `kind`, described by `message`. Never put a secret in + /// the message. + pub fn new(kind: BackendErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + vendor: PhantomData, + } + } + + /// No credentials are configured, or they were refused. + pub fn access_denied(message: impl Into) -> Self { + Self::new(BackendErrorKind::AccessDenied, message) + } + + /// The credential source did not answer. + pub fn unavailable(message: impl Into) -> Self { + Self::new(BackendErrorKind::Unavailable, message) + } + + /// The failure kind the client reports. + pub fn kind(&self) -> BackendErrorKind { + self.kind + } +} + +impl fmt::Debug for CredentialError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CredentialError") + .field("vendor", &K::LABEL) + .field("kind", &self.kind) + .field("message", &self.message) + .finish() + } +} + +impl fmt::Display for CredentialError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", K::LABEL, self.message) + } +} + +impl std::error::Error for CredentialError {} + +/// How [`CachedToken`] gets a credential of kind `Self` from a provider +/// `P`. A vendor crate implements this on its marker type, for every type +/// that implements its own provider trait. +pub trait FetchCredential

: CredentialKind { + /// Get a new credential for `key` from `provider`. + /// + /// The returned future is bounded by [`MaybeSend`]: `Send` on native + /// targets, unbounded on wasm32. + fn fetch<'a>( + provider: &'a P, + key: &'a Self::Key, + ) -> impl Future>> + MaybeSend + 'a; + + /// The vendor refused a credential of `provider`. The default does + /// nothing. + fn invalidate(provider: &P) { + let _ = provider; + } +} + +/// The default time before a credential expires at which [`CachedToken`] +/// gets a new one. For a short-lived credential the window is a third of +/// its life. +pub const DEFAULT_REFRESH_BEFORE_EXPIRY: Duration = Duration::from_secs(5 * 60); + +/// A credential in the cache, and when to get the next one. +struct Cached { + credential: C, + /// When to get a new credential. `None`: never. + refresh_at: Option, + /// When the credential expires. `None`: never. + expires_at: Option, +} + +/// Keeps the credential `inner` returns for each key, and gets a new one +/// when it is near its expiry: [`DEFAULT_REFRESH_BEFORE_EXPIRY`] before, or +/// a third of its life before when it lives less than three times that. +/// +/// One fetch runs at a time: callers that arrive during a fetch wait for it +/// and share its result. If a fetch fails while the cached credential has +/// not expired, the cached credential is still returned. A credential with +/// no expiry ([`Expiry::Never`]) is kept until +/// [`invalidate`](Self::invalidate). +/// +/// `K` is the vendor crate's marker type ([`CredentialKind`]). +pub struct CachedToken { + inner: P, + cached: SyncMutex>>, + fetch: Mutex<()>, + refresh_before: Duration, + clock: Clock, +} + +impl fmt::Debug for CachedToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CachedToken") + .field("inner", &self.inner) + .field("refresh_before", &self.refresh_before) + .finish_non_exhaustive() + } +} + +impl CachedToken { + /// Cache the credentials of `inner`. + pub fn new(inner: P) -> Self { + Self { + inner, + cached: SyncMutex::new(HashMap::new()), + fetch: Mutex::new(()), + refresh_before: DEFAULT_REFRESH_BEFORE_EXPIRY, + clock: system_clock(), + } + } + + /// Get a new credential at most `refresh_before` before the credential + /// expires. + pub fn with_refresh_before(mut self, refresh_before: Duration) -> Self { + self.refresh_before = refresh_before; + self + } + + /// Read the time from `clock`. + pub fn with_clock(mut self, clock: Clock) -> Self { + self.clock = clock; + self + } + + /// The provider whose credentials are cached. + pub fn inner(&self) -> &P { + &self.inner + } + + /// The cached credential of `key`, if it does not need a refresh yet. + fn fresh(&self, key: &Q, now: SystemTime) -> Option + where + K::Key: Borrow, + Q: Hash + Eq + ?Sized, + { + let cached = self.cached.lock().unwrap_or_else(PoisonError::into_inner); + cached + .get(key) + .filter(|cached| cached.refresh_at.is_none_or(|at| now < at)) + .map(|cached| cached.credential.clone()) + } + + /// When to refresh a credential got at `now`, and when it expires. + fn schedule( + &self, + expiry: Expiry, + now: SystemTime, + ) -> (Option, Option) { + let expires_at = match expiry { + Expiry::Never => return (None, None), + Expiry::After(life) => now.checked_add(life), + Expiry::At(at) => Some(at), + }; + let Some(expires_at) = expires_at else { + // Past the end of time: as good as no expiry. + return (None, None); + }; + let life = expires_at.duration_since(now).unwrap_or(Duration::ZERO); + let window = self.refresh_before.min(life / 3); + (expires_at.checked_sub(window), Some(expires_at)) + } +} + +impl> CachedToken { + /// The credential of `key`: the cached one, or a new one from the + /// provider when the cached one is near its expiry. + pub async fn get(&self, key: &Q) -> Result> + where + K::Key: Borrow, + Q: Hash + Eq + ToOwned + ?Sized, + { + if let Some(credential) = self.fresh(key, (self.clock)()) { + return Ok(credential); + } + let _fetch = self.fetch.lock().await; + // Another caller may have got a credential while this one waited. + let now = (self.clock)(); + if let Some(credential) = self.fresh(key, now) { + return Ok(credential); + } + let key = key.to_owned(); + match K::fetch(&self.inner, &key).await { + Ok(credential) => { + let (refresh_at, expires_at) = self.schedule(credential.expiry(), now); + let _ = self + .cached + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert( + key, + Cached { + credential: credential.clone(), + refresh_at, + expires_at, + }, + ); + Ok(credential) + } + Err(error) => { + let cached = self.cached.lock().unwrap_or_else(PoisonError::into_inner); + match cached.get::(&key) { + Some(cached) if cached.expires_at.is_some_and(|end| now < end) => { + Ok(cached.credential.clone()) + } + _ => Err(error), + } + } + } + } + + /// The vendor refused a credential: drop every cached credential, so + /// that the next [`get`](Self::get) fetches a new one, and tell the + /// provider ([`FetchCredential::invalidate`]). + pub fn invalidate(&self) { + self.cached + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clear(); + K::invalidate(&self.inner); + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; + use std::sync::Arc; + + use web_time::UNIX_EPOCH; + + use super::*; + + /// The test vendor: one token per key (a `String`). + enum Test {} + + impl CredentialKind for Test { + const LABEL: &'static str = "Test credentials"; + type Key = String; + type Credential = AccessToken; + } + + /// The expiry the provider gives its tokens. + #[derive(Clone, Copy)] + enum Life { + Never, + After(u64), + /// Expires at this many seconds after the test clock's start. + At(u64), + } + + struct Counting { + calls: AtomicUsize, + invalidations: AtomicUsize, + fail: AtomicBool, + life: Life, + /// Held by a test to keep a fetch waiting. + gate: Mutex<()>, + } + + /// A token whose `expiry` is not the one `AccessToken` reports, to + /// test [`Expiry::At`]. + #[derive(Clone)] + struct Timed(AccessToken, Expiry); + + impl Expiring for Timed { + fn expiry(&self) -> Expiry { + self.1 + } + } + + enum TimedTest {} + + impl CredentialKind for TimedTest { + const LABEL: &'static str = "Timed credentials"; + type Key = (); + type Credential = Timed; + } + + const START: u64 = 1_000_000; + + fn at(seconds: u64) -> SystemTime { + UNIX_EPOCH + Duration::from_secs(seconds) + } + + impl Counting { + async fn next(&self, key: &str) -> Result<(AccessToken, Expiry), String> { + let _gate = self.gate.lock().await; + let call = self.calls.fetch_add(1, Ordering::SeqCst); + if self.fail.load(Ordering::SeqCst) { + return Err("down".to_owned()); + } + let token = AccessToken::new(format!("{key}-{call}")); + Ok(match self.life { + Life::Never => (token, Expiry::Never), + Life::After(seconds) => { + let token = token.with_expires_in(Duration::from_secs(seconds)); + let expiry = token.expiry(); + (token, expiry) + } + Life::At(seconds) => (token, Expiry::At(at(START + seconds))), + }) + } + } + + impl FetchCredential for Test { + async fn fetch( + provider: &Counting, + key: &String, + ) -> Result> { + provider + .next(key) + .await + .map(|(token, _)| token) + .map_err(CredentialError::unavailable) + } + + fn invalidate(provider: &Counting) { + provider.invalidations.fetch_add(1, Ordering::SeqCst); + } + } + + impl FetchCredential for TimedTest { + async fn fetch(provider: &Counting, _key: &()) -> Result> { + provider + .next("t") + .await + .map(|(token, expiry)| Timed(token, expiry)) + .map_err(CredentialError::unavailable) + } + } + + fn provider(life: Life) -> Counting { + Counting { + calls: AtomicUsize::new(0), + invalidations: AtomicUsize::new(0), + fail: false.into(), + life, + gate: Mutex::new(()), + } + } + + fn clock() -> (Clock, Arc) { + let now = Arc::new(AtomicU64::new(START)); + let clock_now = Arc::clone(&now); + let clock: Clock = Arc::new(move || at(clock_now.load(Ordering::SeqCst))); + (clock, now) + } + + fn cached(life: Life) -> (CachedToken, Arc) { + let (clock, now) = clock(); + (CachedToken::new(provider(life)).with_clock(clock), now) + } + + async fn token_of(cache: &CachedToken, key: &str) -> String { + cache.get(key).await.unwrap().token().to_owned() + } + + #[tokio::test] + async fn a_credential_is_got_again_before_it_expires() { + let (cache, now) = cached(Life::After(3600)); + assert_eq!(token_of(&cache, "v").await, "v-0"); + now.fetch_add(3600 - 301, Ordering::SeqCst); + assert_eq!(token_of(&cache, "v").await, "v-0"); + now.fetch_add(1, Ordering::SeqCst); + assert_eq!(token_of(&cache, "v").await, "v-1"); + assert_eq!(cache.inner().calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn a_short_life_refreshes_a_third_before_its_end() { + let (cache, now) = cached(Life::After(60)); + assert_eq!(token_of(&cache, "v").await, "v-0"); + now.fetch_add(39, Ordering::SeqCst); + assert_eq!(token_of(&cache, "v").await, "v-0"); + now.fetch_add(1, Ordering::SeqCst); + assert_eq!(token_of(&cache, "v").await, "v-1"); + } + + #[tokio::test] + async fn a_credential_with_no_expiry_is_kept_until_invalidated() { + let (cache, now) = cached(Life::Never); + assert_eq!(token_of(&cache, "v").await, "v-0"); + now.fetch_add(10 * 365 * 86_400, Ordering::SeqCst); + assert_eq!(token_of(&cache, "v").await, "v-0"); + assert_eq!(cache.inner().invalidations.load(Ordering::SeqCst), 0); + cache.invalidate(); + assert_eq!(cache.inner().invalidations.load(Ordering::SeqCst), 1); + assert_eq!(token_of(&cache, "v").await, "v-1"); + } + + #[tokio::test] + async fn a_failed_fetch_keeps_a_credential_that_has_not_expired() { + let (cache, now) = cached(Life::After(3600)); + assert_eq!(token_of(&cache, "v").await, "v-0"); + cache.inner().fail.store(true, Ordering::SeqCst); + now.fetch_add(3500, Ordering::SeqCst); + assert_eq!(token_of(&cache, "v").await, "v-0"); + // At the expiry the credential is not returned. + now.fetch_add(100, Ordering::SeqCst); + let error = cache.get("v").await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::Unavailable); + // Another key has nothing cached to fall back on. + let error = cache.get("w").await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::Unavailable); + } + + #[tokio::test] + async fn a_failed_fetch_returns_a_credential_until_its_last_second() { + let (cache, now) = cached(Life::After(3600)); + assert_eq!(token_of(&cache, "v").await, "v-0"); + cache.inner().fail.store(true, Ordering::SeqCst); + now.fetch_add(3599, Ordering::SeqCst); + assert_eq!(token_of(&cache, "v").await, "v-0"); + } + + #[tokio::test] + async fn a_failed_fetch_with_no_expiry_is_an_error() { + let (cache, _) = cached(Life::Never); + cache.inner().fail.store(true, Ordering::SeqCst); + assert!(cache.get("v").await.is_err()); + } + + #[tokio::test] + async fn the_refresh_window_is_configurable() { + let (cache, now) = cached(Life::After(3600)); + let cache = cache.with_refresh_before(Duration::ZERO); + assert_eq!(token_of(&cache, "v").await, "v-0"); + now.fetch_add(3599, Ordering::SeqCst); + assert_eq!(token_of(&cache, "v").await, "v-0"); + now.fetch_add(1, Ordering::SeqCst); + assert_eq!(token_of(&cache, "v").await, "v-1"); + } + + #[tokio::test] + async fn each_key_has_its_own_credential() { + let (cache, _) = cached(Life::After(3600)); + assert_eq!(token_of(&cache, "vault").await, "vault-0"); + assert_eq!(token_of(&cache, "hsm").await, "hsm-1"); + assert_eq!(token_of(&cache, "vault").await, "vault-0"); + assert_eq!(token_of(&cache, "hsm").await, "hsm-1"); + cache.invalidate(); + assert_eq!(token_of(&cache, "hsm").await, "hsm-2"); + assert_eq!(token_of(&cache, "vault").await, "vault-3"); + } + + #[tokio::test] + async fn callers_during_a_fetch_share_its_result() { + let (cache, _) = cached(Life::After(3600)); + let gate = cache.inner().gate.lock().await; + let open = async { + // Let both callers start before the fetch can end. + tokio::task::yield_now().await; + drop(gate); + }; + let (first, second, ()) = futures::join!(cache.get("v"), cache.get("v"), open); + assert_eq!(first.unwrap().token(), "v-0"); + assert_eq!(second.unwrap().token(), "v-0"); + assert_eq!(cache.inner().calls.load(Ordering::SeqCst), 1); + } + + fn timed(life: Life) -> (CachedToken, Arc) { + let (clock, now) = clock(); + (CachedToken::new(provider(life)).with_clock(clock), now) + } + + async fn timed_token(cache: &CachedToken) -> String { + cache.get(&()).await.unwrap().0.token().to_owned() + } + + #[tokio::test] + async fn a_credential_with_an_expiry_time_is_refreshed_before_it() { + let (cache, now) = timed(Life::At(3600)); + assert_eq!(timed_token(&cache).await, "t-0"); + now.fetch_add(3600 - 301, Ordering::SeqCst); + assert_eq!(timed_token(&cache).await, "t-0"); + now.fetch_add(1, Ordering::SeqCst); + assert_eq!(timed_token(&cache).await, "t-1"); + } + + #[tokio::test] + async fn an_expiry_time_measures_the_life_from_the_fetch() { + // Got at START, expires at START + 60: refreshed 20 seconds early. + let (cache, now) = timed(Life::At(60)); + assert_eq!(timed_token(&cache).await, "t-0"); + now.fetch_add(39, Ordering::SeqCst); + assert_eq!(timed_token(&cache).await, "t-0"); + now.fetch_add(1, Ordering::SeqCst); + assert_eq!(timed_token(&cache).await, "t-1"); + } + + #[tokio::test] + async fn a_credential_that_has_already_expired_is_not_kept() { + let (cache, now) = timed(Life::At(0)); + now.fetch_add(10, Ordering::SeqCst); + assert_eq!(timed_token(&cache).await, "t-0"); + assert_eq!(timed_token(&cache).await, "t-1"); + cache.inner().fail.store(true, Ordering::SeqCst); + assert!(cache.get(&()).await.is_err()); + } + + #[test] + fn an_access_token_reports_its_expiry() { + assert_eq!(AccessToken::new("t").expiry(), Expiry::Never); + let token = AccessToken::new("t").with_expires_in(Duration::from_secs(9)); + assert_eq!(token.token(), "t"); + assert_eq!(token.expires_in(), Some(Duration::from_secs(9))); + assert_eq!(token.expiry(), Expiry::After(Duration::from_secs(9))); + } + + #[tokio::test] + async fn debug_hides_the_secret() { + let text = format!("{:?}", AccessToken::new("the-access-token")); + assert!(text.contains("AccessToken")); + assert!(!text.contains("the-access-token")); + let (cache, _) = cached(Life::Never); + let _ = cache.get("v").await.unwrap(); + let text = format!("{cache:?}"); + assert!(text.contains("CachedToken")); + assert!(!text.contains("v-0")); + } + + impl fmt::Debug for Counting { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Counting") + } + } + + #[test] + fn an_error_names_its_vendor_and_kind() { + let error = CredentialError::::access_denied("no key"); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + assert_eq!(error.to_string(), "Test credentials: no key"); + assert!(format!("{error:?}").contains("Test credentials")); + let error = CredentialError::::unavailable("down"); + assert_eq!(error.kind(), BackendErrorKind::Unavailable); + let error = CredentialError::::new(BackendErrorKind::Throttled, "slow"); + assert_eq!(error.kind(), BackendErrorKind::Throttled); + assert_eq!(error.to_string(), "Test credentials: slow"); + } +} diff --git a/packages/stack-kms-vendor/src/derive.rs b/packages/stack-kms-vendor/src/derive.rs new file mode 100644 index 0000000000..f5d717c453 --- /dev/null +++ b/packages/stack-kms-vendor/src/derive.rs @@ -0,0 +1,351 @@ +//! The key derivations and the associated data of a key vendor backend. +//! +//! These bytes are a storage commitment: a stored value opens only if its +//! data key derives exactly as it did when it was sealed. The tests pin +//! them. +//! +//! # Framing +//! +//! Every label, info and associated data is a list of byte strings framed +//! with [`frame`]: the count of pieces, then each piece as its length and +//! its bytes, every number a `u64` little-endian. The framing is +//! prefix-free, so two different lists never give the same bytes. +//! +//! # Derived data keys +//! +//! * data key = `HKDF-SHA256(ikm = root key, salt = iv, info = +//! frame[DATA_KEY_LABEL, keyset id, vendor byte, mode byte, +//! descriptor])`, 32 bytes. +//! * index key = `HKDF-SHA256(ikm = root key, salt = none, info = +//! frame[INDEX_KEY_LABEL, keyset id, vendor byte, mode byte])`, 32 +//! bytes. +//! +//! The iv is 16 random bytes per value, stored in the leaf, and the leaf's +//! tag is empty. The leaf is the v1 leaf and names no key backend, so the +//! backend and the mode are bound here instead: a +//! keyset record whose vendor or mode was changed derives other keys, and +//! every value fails authentication. +//! +//! # Associated data +//! +//! The root key in a keyset record is wrapped with +//! `frame[RECORD_KEY_LABEL, "root-key", keyset id, vendor byte, mode +//! byte]`. A record whose id, vendor or mode was changed does not unwrap. + +use hkdf::Hkdf; +use sha2::Sha256; +use stack_kms::{KeyMode, KeyVendor}; +use uuid::Uuid; +use vitaminc::protected::{Controlled, Protected}; +use zeroize::Zeroizing; + +use crate::vendor::KeyBytes; + +/// The HKDF info label of a derived data key. +pub const DATA_KEY_LABEL: &[u8] = b"stack-kms-vendor/derived-data-key/v1"; +/// The HKDF info label of a derived index key. +pub const INDEX_KEY_LABEL: &[u8] = b"stack-kms-vendor/derived-index-key/v1"; +/// The associated-data label of the key a keyset record wraps. +pub const RECORD_KEY_LABEL: &[u8] = b"stack-kms-vendor/keyset-record-key/v1"; + +/// Frame a list of byte strings without ambiguity (see the module docs). +pub fn frame(pieces: &[&[u8]]) -> Vec { + let len = 8 + pieces.iter().map(|piece| 8 + piece.len()).sum::(); + let mut out = Vec::with_capacity(len); + out.extend_from_slice(&(pieces.len() as u64).to_le_bytes()); + for piece in pieces { + out.extend_from_slice(&(piece.len() as u64).to_le_bytes()); + out.extend_from_slice(piece); + } + out +} + +/// What the key in a keyset record is: the root key. The purpose is bound +/// into the record's associated data, so that a key wrapped for another +/// purpose by a later key mode does not unwrap as a root key. +pub const RECORD_KEY_PURPOSE: &[u8] = b"root-key"; + +/// The associated data the root key in a keyset record is wrapped with. +pub fn record_key_aad(keyset_id: Uuid, vendor: KeyVendor, mode: KeyMode) -> Vec { + frame(&[ + RECORD_KEY_LABEL, + RECORD_KEY_PURPOSE, + keyset_id.as_bytes(), + &[vendor.byte()], + &[mode.byte()], + ]) +} + +/// Split bytes made by [`frame`] back into their pieces. `None` if the +/// bytes are not exactly one frame. +pub fn unframe(bytes: &[u8]) -> Option> { + fn take_u64(bytes: &[u8]) -> Option<(usize, &[u8])> { + let (head, rest) = bytes.split_at_checked(8)?; + let value = u64::from_le_bytes(head.try_into().ok()?); + Some((usize::try_from(value).ok()?, rest)) + } + let (count, mut rest) = take_u64(bytes)?; + // Each piece takes at least 8 bytes, so a larger count cannot fit. + if count > rest.len() / 8 { + return None; + } + let mut pieces = Vec::with_capacity(count); + for _ in 0..count { + let (len, after) = take_u64(rest)?; + let (piece, after) = after.split_at_checked(len)?; + pieces.push(piece); + rest = after; + } + rest.is_empty().then_some(pieces) +} + +/// What a vendor may show of the associated data it is given, for audit +/// logs and access policies: the vendor's own context fields (AWS KMS +/// encryption context, for example). See [`describe_aad`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AadDescription<'a> { + /// What the wrapped key is: `root-key`, the key in a keyset record. + pub purpose: &'a str, + /// The keyset the key belongs to. + pub keyset_id: Uuid, +} + +/// Describe associated data made by [`record_key_aad`]. `None` for any +/// other bytes. +/// +/// The description is for a vendor that shows its associated data as +/// fields. It does not replace the associated data: the vendor must still +/// bind all of `aad` (see +/// [`KeyVendorBackend`](crate::KeyVendorBackend)), because the description +/// leaves out the vendor byte and the mode byte. +pub fn describe_aad(aad: &[u8]) -> Option> { + let pieces = unframe(aad)?; + match pieces.as_slice() { + [label, purpose, id, vendor, mode] + if *label == RECORD_KEY_LABEL + && *purpose == RECORD_KEY_PURPOSE + && vendor.len() == 1 + && mode.len() == 1 => + { + Some(AadDescription { + purpose: "root-key", + keyset_id: Uuid::from_slice(id).ok()?, + }) + } + _ => None, + } +} + +/// `HKDF-SHA256(ikm, salt, info)`, 32 bytes, into a key that wipes itself. +fn hkdf(ikm: &KeyBytes, salt: Option<&[u8]>, info: &[u8]) -> KeyBytes { + let hkdf = Hkdf::::new(salt, ikm.risky_ref()); + let mut okm = Zeroizing::new([0u8; 32]); + // 32 bytes is far below HKDF-SHA256's limit of 255 * 32. + #[allow(clippy::expect_used)] + hkdf.expand(info, okm.as_mut()) + .expect("32 bytes is a valid HKDF-SHA256 output length"); + Protected::new(*okm) +} + +/// The data key of one value under derived data keys, for a keyset of +/// `vendor` in `mode`. +pub fn derived_data_key( + root: &KeyBytes, + keyset_id: Uuid, + vendor: KeyVendor, + mode: KeyMode, + iv: &[u8; 16], + descriptor: &str, +) -> KeyBytes { + let info = frame(&[ + DATA_KEY_LABEL, + keyset_id.as_bytes(), + &[vendor.byte()], + &[mode.byte()], + descriptor.as_bytes(), + ]); + hkdf(root, Some(iv), &info) +} + +/// The index key of a keyset of `vendor` in `mode`, under derived data +/// keys. +pub fn derived_index_key( + root: &KeyBytes, + keyset_id: Uuid, + vendor: KeyVendor, + mode: KeyMode, +) -> KeyBytes { + let info = frame(&[ + INDEX_KEY_LABEL, + keyset_id.as_bytes(), + &[vendor.byte()], + &[mode.byte()], + ]); + hkdf(root, None, &info) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + /// RFC 5869 test case 1, cut to the first 32 bytes of output (one + /// HMAC block, which is all a 32-byte key uses). Proves the HKDF here + /// is plain HKDF-SHA256. + #[test] + fn hkdf_matches_rfc_5869_test_case_1() { + let ikm = Protected::new([0x0bu8; 22]); + let hk = Hkdf::::new( + Some(&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + ikm.risky_ref(), + ); + let mut okm = [0u8; 32]; + hk.expand( + &[0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9], + &mut okm, + ) + .unwrap(); + assert_eq!( + hex(&okm), + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf" + ); + } + + #[test] + fn framing_is_prefix_free() { + assert_ne!(frame(&[b"ab", b"c"]), frame(&[b"a", b"bc"])); + assert_ne!(frame(&[b"abc"]), frame(&[b"abc", b""])); + assert_eq!(hex(&frame(&[b"a"])), "0100000000000000010000000000000061"); + } + + const AWS: KeyVendor = KeyVendor::Aws; + const DERIVED: KeyMode = KeyMode::Derived; + + /// The derivations are a storage commitment: these pins fail if one + /// moves. + #[test] + fn the_derivations_are_pinned() { + let root = Protected::new([7u8; 32]); + let id = Uuid::from_u128(0x0102_0304_0506_0708_090a_0b0c_0d0e_0f10); + let data = derived_data_key(&root, id, AWS, DERIVED, &[9u8; 16], "users/email"); + let index = derived_index_key(&root, id, AWS, DERIVED); + assert_eq!( + hex(data.risky_ref()), + hex(derived_data_key(&root, id, AWS, DERIVED, &[9u8; 16], "users/email").risky_ref()) + ); + assert_ne!(data.risky_ref(), index.risky_ref()); + assert_eq!( + hex(data.risky_ref()), + "f61d938e85e3a0ac8c23f7272710a7a7e2539597a2f5ae7c71512b5d2cd0945c" + ); + assert_eq!( + hex(index.risky_ref()), + "b64982a8d277024fbec5e2ce37ab24db3fe3af17788593da8edc47c1b60819c9" + ); + } + + #[test] + fn every_input_changes_the_data_key() { + let root = Protected::new([7u8; 32]); + let id = Uuid::from_u128(1); + let base = derived_data_key(&root, id, AWS, DERIVED, &[1u8; 16], "d"); + let others = [ + derived_data_key( + &Protected::new([8u8; 32]), + id, + AWS, + DERIVED, + &[1u8; 16], + "d", + ), + derived_data_key(&root, Uuid::from_u128(2), AWS, DERIVED, &[1u8; 16], "d"), + derived_data_key(&root, id, KeyVendor::Vault, DERIVED, &[1u8; 16], "d"), + derived_data_key(&root, id, AWS, DERIVED, &[2u8; 16], "d"), + derived_data_key(&root, id, AWS, DERIVED, &[1u8; 16], "e"), + ]; + for other in others { + assert_ne!(base.risky_ref(), other.risky_ref()); + } + let index = derived_index_key(&root, id, AWS, DERIVED); + assert_ne!( + index.risky_ref(), + derived_index_key(&root, Uuid::from_u128(2), AWS, DERIVED).risky_ref() + ); + assert_ne!( + index.risky_ref(), + derived_index_key(&root, id, KeyVendor::Gcp, DERIVED).risky_ref(), + "the vendor is bound into the index key" + ); + } + + /// The mode byte is in the info of both derivations, so a record with + /// another mode byte cannot derive the keys of a derived keyset. + /// Checked on the frame, since there is no other mode. + #[test] + fn the_vendor_and_mode_bytes_are_in_the_info() { + let id = Uuid::from_u128(1); + let info = frame(&[INDEX_KEY_LABEL, id.as_bytes(), &[AWS.byte()], &[1]]); + let root = Protected::new([7u8; 32]); + assert_ne!( + hkdf(&root, None, &info).risky_ref(), + derived_index_key(&root, id, AWS, DERIVED).risky_ref() + ); + } + + #[test] + fn unframe_reverses_frame_and_refuses_anything_else() { + let pieces: [&[u8]; 3] = [b"ab", b"", b"cde"]; + let framed = frame(&pieces); + assert_eq!(unframe(&framed).unwrap(), pieces); + assert_eq!(unframe(&frame(&[])).unwrap(), Vec::<&[u8]>::new()); + let mut long = framed.clone(); + long.push(0); + assert_eq!(unframe(&long), None); + assert_eq!(unframe(&framed[..framed.len() - 1]), None); + assert_eq!(unframe(&[1, 0, 0]), None); + assert_eq!(unframe(&u64::MAX.to_le_bytes()), None); + } + + #[test] + fn describe_aad_names_the_purpose_and_keyset() { + let id = Uuid::from_u128(7); + assert_eq!( + describe_aad(&record_key_aad(id, AWS, DERIVED)), + Some(AadDescription { + purpose: "root-key", + keyset_id: id, + }) + ); + assert_eq!(describe_aad(b"not a frame"), None); + assert_eq!(describe_aad(&frame(&[b"other-label", id.as_bytes()])), None); + assert_eq!( + describe_aad(&frame(&[RECORD_KEY_LABEL, b"x", id.as_bytes(), &[1], &[2]])), + None + ); + assert_eq!( + describe_aad(&frame(&[ + RECORD_KEY_LABEL, + RECORD_KEY_PURPOSE, + b"short", + &[1], + &[2] + ])), + None + ); + } + + #[test] + fn the_record_aad_binds_every_field() { + let id = Uuid::from_u128(1); + let base = record_key_aad(id, AWS, DERIVED); + assert_ne!(base, record_key_aad(Uuid::from_u128(2), AWS, DERIVED)); + assert_ne!(base, record_key_aad(id, KeyVendor::Vault, DERIVED)); + assert_eq!( + hex(&base), + "05000000000000002500000000000000737461636b2d6b6d732d76656e646f722f6b65797365742d7265636f72642d6b65792f76310800000000000000726f6f742d6b6579100000000000000000000000000000000000000000000001010000000000000001010000000000000002" + ); + } +} diff --git a/packages/stack-kms-vendor/src/error.rs b/packages/stack-kms-vendor/src/error.rs new file mode 100644 index 0000000000..0ecd909453 --- /dev/null +++ b/packages/stack-kms-vendor/src/error.rs @@ -0,0 +1,140 @@ +//! Errors from keyset records and from building a key vendor backend. + +use stack_kms::{diagnostic::payload, ErrorPayload, KeyVendor}; +use uuid::Uuid; + +/// A keyset record could not be parsed, created or loaded. +/// +/// No message carries key material or a wrapped key. A parse error names +/// what is wrong with the record, not the record. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] +pub enum RecordError { + /// The text does not start with [`RECORD_PREFIX`](crate::RECORD_PREFIX). + #[error("This is not a keyset record: it does not start with `stack-keyset:`")] + #[diagnostic( + code(stack_kms_vendor::record_prefix), + help("Copy the whole keyset record, as `stash keyset create` printed it.") + )] + NotARecord, + + /// The text after the prefix is not unpadded base64url. + #[error("The keyset record is not valid base64url")] + #[diagnostic(code(stack_kms_vendor::record_encoding))] + Encoding, + + /// The record has a format version this build does not know. + #[error("The keyset record has format version {0}, which this build does not know")] + #[diagnostic( + code(stack_kms_vendor::record_version), + help("Upgrade to a release that reads this record version.") + )] + UnknownVersion(u8), + + /// The record names a key backend this build does not know. + #[error("The keyset record names key backend {0}, which this build does not know")] + #[diagnostic(code(stack_kms_vendor::record_backend))] + UnknownBackend(u8), + + /// The record names a key mode this build does not know. + #[error("The keyset record names key mode {0}, which this build does not know")] + #[diagnostic(code(stack_kms_vendor::record_mode))] + UnknownMode(u8), + + /// The record is shorter than its fields say, or has bytes after them. + #[error("The keyset record is damaged: its length does not match its fields")] + #[diagnostic(code(stack_kms_vendor::record_length))] + Length, + + /// A field holds a value it may not hold. + #[error("The keyset record field `{field}` is not valid")] + #[diagnostic( + code(stack_kms_vendor::record_field), + help("A name is 1 to 255 bytes of UTF-8. A vendor key reference and a wrapped key are 1 to 65535 bytes.") + )] + InvalidField { + /// The field. + field: &'static str, + }, + + /// A record names another key vendor backend than the one it was given + /// to. + #[error("The keyset record {keyset} names key backend {found}, not {expected}")] + #[diagnostic(code(stack_kms_vendor::foreign_vendor))] + ForeignVendor { + /// The keyset. + keyset: Uuid, + /// The vendor of the backend. + expected: KeyVendor, + /// The vendor the record names. + found: KeyVendor, + }, + + /// Two records name the same keyset id. + #[error("Two keyset records have the id {0}")] + #[diagnostic(code(stack_kms_vendor::duplicate_keyset))] + DuplicateKeyset(Uuid), + + /// Two records have the same name. + #[error("Two keyset records have the same name")] + #[diagnostic( + code(stack_kms_vendor::duplicate_name), + help("A keyset name resolves locally, so it must be unique among the loaded records.") + )] + DuplicateName, + + /// The records name more than one key vendor backend. A cipher holds + /// ZeroKMS and at most one key vendor backend (ADR-0013). + #[error("The keyset records name two key vendor backends, {first} and {second}")] + #[diagnostic( + code(stack_kms_vendor::second_vendor), + help("A cipher holds ZeroKMS and at most one key vendor backend. Load the records of one vendor.") + )] + SecondVendor { + /// The vendor named first. + first: KeyVendor, + /// Another vendor. + second: KeyVendor, + }, + + /// The default keyset is not one of the loaded records. + #[error("The default keyset is not one of the loaded keyset records")] + #[diagnostic(code(stack_kms_vendor::unknown_default))] + UnknownDefault, + + /// The vendor call that created or rewrapped the record failed. + #[error(transparent)] + #[diagnostic(transparent)] + Kms(#[from] stack_kms::Error), +} + +impl ErrorPayload for RecordError { + fn payload(&self) -> serde_json::Map { + match self { + Self::UnknownVersion(byte) | Self::UnknownBackend(byte) | Self::UnknownMode(byte) => { + payload([("byte", (*byte).into())]) + } + Self::InvalidField { field } => payload([("field", (*field).into())]), + Self::ForeignVendor { + keyset, + expected, + found, + } => payload([ + ("keyset", keyset.to_string().into()), + ("expected", expected.name().into()), + ("found", found.name().into()), + ]), + Self::DuplicateKeyset(keyset) => payload([("keyset", keyset.to_string().into())]), + Self::SecondVendor { first, second } => payload([ + ("first", first.name().into()), + ("second", second.name().into()), + ]), + Self::Kms(error) => error.payload(), + Self::NotARecord + | Self::Encoding + | Self::Length + | Self::DuplicateName + | Self::UnknownDefault => serde_json::Map::new(), + } + } +} diff --git a/packages/stack-kms-vendor/src/fake.rs b/packages/stack-kms-vendor/src/fake.rs new file mode 100644 index 0000000000..1800859ff1 --- /dev/null +++ b/packages/stack-kms-vendor/src/fake.rs @@ -0,0 +1,154 @@ +//! An in-memory key vendor backend for tests. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use stack_kms::{BackendErrorKind, Error, KeyVendor}; +use vitaminc::protected::Protected; + +use crate::static_key::StaticKeyVendor; +use crate::vendor::{KeyBytes, KeyVendorBackend}; + +/// How many calls a [`FakeVendor`] has answered, by operation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FakeVendorCalls { + /// Calls to [`wrap`](KeyVendorBackend::wrap). + pub wrap: usize, + /// Calls to [`unwrap`](KeyVendorBackend::unwrap). + pub unwrap: usize, + /// Calls refused as throttled. + pub throttled: usize, +} + +/// An in-memory key vendor backend, for tests +/// and for the [contract suite](crate::contract). +/// +/// It wraps with AES-256-GCM under in-memory vendor keys, as +/// [`StaticKeyVendor`] does, but reports itself as the vendor it was built +/// with, counts its calls, can refuse the next calls as throttled, and +/// records the waits the adapter asks for. It does no I/O. +pub struct FakeVendor { + vendor: KeyVendor, + inner: StaticKeyVendor, + wraps: AtomicUsize, + unwraps: AtomicUsize, + throttled: AtomicUsize, + throttle_next: AtomicUsize, + sleeps: Mutex>, +} + +impl std::fmt::Debug for FakeVendor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FakeVendor") + .field("vendor", &self.vendor) + .field("calls", &self.calls()) + .finish() + } +} + +impl FakeVendor { + /// The vendor key reference every fake vendor holds. + pub const KEY_REF: &'static str = "fake-key/v1"; + + /// A fake that reports itself as `vendor`, with one vendor key, + /// [`KEY_REF`](Self::KEY_REF). + pub fn new(vendor: KeyVendor) -> Self { + Self { + vendor, + inner: StaticKeyVendor::new(Self::KEY_REF, Protected::new([0x5au8; 32])), + wraps: AtomicUsize::new(0), + unwraps: AtomicUsize::new(0), + throttled: AtomicUsize::new(0), + throttle_next: AtomicUsize::new(0), + sleeps: Mutex::new(Vec::new()), + } + } + + /// Add another vendor key, for rewrap tests. + pub fn with_key(mut self, key_ref: &str, key: KeyBytes) -> Self { + self.inner = self.inner.with_key(key_ref, key); + self + } + + /// Refuse the next `count` calls as throttled. + pub fn throttle_next(&self, count: usize) { + self.throttle_next.store(count, Ordering::SeqCst); + } + + /// The calls answered so far. + pub fn calls(&self) -> FakeVendorCalls { + FakeVendorCalls { + wrap: self.wraps.load(Ordering::SeqCst), + unwrap: self.unwraps.load(Ordering::SeqCst), + throttled: self.throttled.load(Ordering::SeqCst), + } + } + + /// The waits the adapter asked for, in order. + pub fn sleeps(&self) -> Vec { + self.sleeps + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + /// Refuse this call as throttled if a throttle is pending. + fn throttle(&self) -> Result<(), Error> { + let pending = self + .throttle_next + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| n.checked_sub(1)); + if pending.is_ok() { + let _ = self.throttled.fetch_add(1, Ordering::SeqCst); + return Err(Error::Backend { + backend: self.vendor.into(), + kind: BackendErrorKind::Throttled, + source: "the fake vendor is throttling".into(), + }); + } + Ok(()) + } + + /// Report the inner static vendor's failure as this vendor's. + fn rename(&self, error: Error) -> Error { + match error { + Error::Backend { kind, source, .. } => Error::Backend { + backend: self.vendor.into(), + kind, + source, + }, + other => other, + } + } +} + +impl KeyVendorBackend for FakeVendor { + fn vendor(&self) -> KeyVendor { + self.vendor + } + + async fn wrap(&self, key_ref: &str, key: &KeyBytes, aad: &[u8]) -> Result, Error> { + self.throttle()?; + let _ = self.wraps.fetch_add(1, Ordering::SeqCst); + self.inner + .wrap(key_ref, key, aad) + .await + .map_err(|error| self.rename(error)) + } + + async fn unwrap(&self, key_ref: &str, wrapped: &[u8], aad: &[u8]) -> Result { + self.throttle()?; + let _ = self.unwraps.fetch_add(1, Ordering::SeqCst); + self.inner + .unwrap(key_ref, wrapped, aad) + .await + .map_err(|error| self.rename(error)) + } + + async fn sleep(&self, duration: Duration) { + self.sleeps + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(duration); + } +} diff --git a/packages/stack-kms-vendor/src/http.rs b/packages/stack-kms-vendor/src/http.rs new file mode 100644 index 0000000000..74ee23e584 --- /dev/null +++ b/packages/stack-kms-vendor/src/http.rs @@ -0,0 +1,239 @@ +//! The native [`VendorTransport`]: HTTPS through reqwest. +//! +//! It uses the same HTTP client and TLS configuration as stack-kms's +//! ZeroKMS transport: reqwest with rustls, and the platform verifier with +//! the compromised-CA blocklist (`stack_auth::tls::client_config`). Native +//! only. The WASI guest sends through its `transport_send` host import. + +use std::time::Duration; + +use zeroize::Zeroizing; + +use crate::transport::{TransportError, VendorRequest, VendorResponse, VendorTransport}; + +/// The default time limit for one request, connection included. +pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// The native [`VendorTransport`]: HTTPS (or plain HTTP, for a local +/// emulator or an instance metadata service) through reqwest. +/// +/// Clone it freely: clones share one connection pool. +#[derive(Clone, Debug)] +pub struct HttpVendorTransport { + client: reqwest::Client, +} + +/// Options for [`HttpVendorTransport`]. +#[derive(Clone, Debug)] +pub struct HttpVendorTransportOptions { + /// The time limit for one request, connection included. Default + /// [`DEFAULT_REQUEST_TIMEOUT`]. + pub timeout: Duration, + /// The time limit to open a connection. `None` keeps the operating + /// system's limit. Set a short one for an instance metadata service, + /// which does not answer off the cloud. + pub connect_timeout: Option, +} + +impl Default for HttpVendorTransportOptions { + fn default() -> Self { + Self { + timeout: DEFAULT_REQUEST_TIMEOUT, + connect_timeout: None, + } + } +} + +impl HttpVendorTransport { + /// A transport with the default options. + pub fn new() -> Result { + Self::with_options(HttpVendorTransportOptions::default()) + } + + /// A transport with `options`. + pub fn with_options(options: HttpVendorTransportOptions) -> Result { + let tls = stack_auth::tls::client_config(false) + .map_err(|error| TransportError(format!("TLS configuration failed: {error}")))?; + let mut builder = reqwest::ClientBuilder::new() + .user_agent(concat!("stack-kms-vendor/", env!("CARGO_PKG_VERSION"))) + .tls_backend_preconfigured(tls) + .timeout(options.timeout); + if let Some(connect_timeout) = options.connect_timeout { + builder = builder.connect_timeout(connect_timeout); + } + let client = builder + .build() + .map_err(|error| TransportError(format!("HTTP client failed to build: {error}")))?; + Ok(Self { client }) + } +} + +impl VendorTransport for HttpVendorTransport { + async fn send(&self, request: VendorRequest) -> Result { + let method = reqwest::Method::from_bytes(request.method.as_bytes()) + .map_err(|_| TransportError(format!("invalid HTTP method {:?}", request.method)))?; + let mut builder = self.client.request(method, request.url.as_str()); + for (name, value) in &request.headers { + builder = builder.header(name.as_str(), value.as_str()); + } + // reqwest takes the body by value: the copy it holds is not wiped. + // The `Zeroizing` copy in `request` is wiped when it drops. + let response = builder + .body(request.body.to_vec()) + .send() + .await + .map_err(|error| TransportError(transport_failure(&error)))?; + let status = response.status().as_u16(); + let headers = response + .headers() + .iter() + .filter_map(|(name, value)| { + value + .to_str() + .ok() + .map(|value| (name.as_str().to_owned(), value.to_owned())) + }) + .collect(); + let body = response + .bytes() + .await + .map_err(|error| TransportError(transport_failure(&error)))?; + Ok(VendorResponse { + status, + headers, + body: Zeroizing::new(body.to_vec()), + }) + } + + async fn sleep(&self, duration: Duration) { + tokio::time::sleep(duration).await; + } +} + +/// The text of a reqwest failure, without the URL: a URL can hold a +/// credential in its query. +fn transport_failure(error: &reqwest::Error) -> String { + let kind = if error.is_timeout() { + "timed out" + } else if error.is_connect() { + "could not connect" + } else if error.is_body() || error.is_decode() { + "could not read the response" + } else { + "failed" + }; + let mut text = format!("the request {kind}"); + // reqwest's own text names the URL; only its causes are kept. + let mut source = std::error::Error::source(error); + while let Some(cause) = source { + text.push_str(": "); + text.push_str(&cause.to_string()); + source = cause.source(); + } + text +} + +#[cfg(test)] +mod tests { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + use super::*; + + /// One HTTP/1.1 exchange on a local socket: read the request, answer + /// with `response`, return what was read. + async fn serve_once(listener: TcpListener, response: &'static [u8]) -> String { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut received = Vec::new(); + let mut buffer = [0u8; 4096]; + loop { + let n = socket.read(&mut buffer).await.unwrap(); + received.extend_from_slice(&buffer[..n]); + let text = String::from_utf8_lossy(&received); + if let Some(end) = text.find("\r\n\r\n") { + let length = text[..end] + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok())? + }) + .unwrap_or(0); + if received.len() >= end + 4 + length { + break; + } + } + } + socket.write_all(response).await.unwrap(); + socket.shutdown().await.unwrap(); + String::from_utf8(received).unwrap() + } + + #[tokio::test] + async fn sends_the_request_and_returns_any_status() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(serve_once( + listener, + b"HTTP/1.1 400 Bad Request\r\ncontent-type: application/x-amz-json-1.1\r\ncontent-length: 2\r\n\r\n{}", + )); + let transport = HttpVendorTransport::new().unwrap(); + let response = transport + .send( + VendorRequest::new("POST", format!("http://{address}/")) + .header("x-amz-target", "TrentService.Decrypt") + .body(b"{\"a\":1}".to_vec()), + ) + .await + .unwrap(); + assert_eq!(response.status, 400); + assert_eq!( + response.header_value("content-type"), + Some("application/x-amz-json-1.1") + ); + assert_eq!(response.body.as_slice(), b"{}"); + let received = server.await.unwrap(); + assert!(received.starts_with("POST / HTTP/1.1\r\n")); + assert!(received.contains("x-amz-target: TrentService.Decrypt\r\n")); + assert!(received.ends_with("{\"a\":1}")); + } + + #[tokio::test] + async fn a_refused_connection_is_a_transport_error() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + let transport = HttpVendorTransport::with_options(HttpVendorTransportOptions { + connect_timeout: Some(Duration::from_secs(1)), + ..HttpVendorTransportOptions::default() + }) + .unwrap(); + let error = transport + .send(VendorRequest::new( + "GET", + format!("http://{address}/?secret=1"), + )) + .await + .unwrap_err(); + assert!(error.0.contains("could not connect"), "{}", error.0); + assert!(!error.0.contains("secret"), "{}", error.0); + } + + #[tokio::test] + async fn an_invalid_method_is_refused() { + let transport = HttpVendorTransport::new().unwrap(); + let error = transport + .send(VendorRequest::new("BAD METHOD", "http://127.0.0.1:1/")) + .await + .unwrap_err(); + assert!(error.0.contains("invalid HTTP method")); + } + + #[tokio::test] + async fn sleep_waits() { + let transport = HttpVendorTransport::new().unwrap(); + let start = std::time::Instant::now(); + transport.sleep(Duration::from_millis(20)).await; + assert!(start.elapsed() >= Duration::from_millis(20)); + } +} diff --git a/packages/stack-kms-vendor/src/lib.rs b/packages/stack-kms-vendor/src/lib.rs new file mode 100644 index 0000000000..da6c4c0fa5 --- /dev/null +++ b/packages/stack-kms-vendor/src/lib.rs @@ -0,0 +1,260 @@ +#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +//! Key vendor backends for Stack Encrypt (ADR-0013, ADR-0014). +//! +//! A **key vendor backend** is a key backend that is not ZeroKMS: AWS KMS, +//! Azure Key Vault, HashiCorp Vault Transit, Google Cloud KMS, or a static +//! key. This crate holds what every vendor shares, so that a vendor crate +//! implements one small trait and nothing else: +//! +//! * [`KeyVendorBackend`]: the vendor seam. A vendor wraps and unwraps a +//! key under its vendor key, with associated data. That is all. +//! * [`KeysetRecord`]: the customer-held description of a keyset in a key +//! vendor backend. It is an opaque, versioned string that the customer +//! keeps in config or in an environment variable. +//! * [`VendorKeySource`]: the adapter that turns one vendor and its keyset +//! records into a [`DataKeySource`](stack_kms::DataKeySource) and an +//! [`IndexKeySource`](stack_kms::IndexKeySource). Vendor crates never +//! implement the stack-kms traits themselves. +//! * [`KeyRouter`]: ZeroKMS and at most one key vendor backend in one key +//! source, routed by keyset. +//! * [`StaticKeyVendor`]: the static key backend, for development or for +//! customers who manage the key themselves. +//! * [`bound`]: associated data bound by us, for a vendor key that has +//! none of its own. The Vault Transit and Azure Key Vault clients use it +//! for every key. +//! * [`retry`]: one vendor call, with backoff on +//! [`Throttled`](stack_kms::BackendErrorKind::Throttled). +//! * [`CachedToken`]: keeps the credential a login returns, and gets a new +//! one before it expires. Every vendor crate caches its credentials with +//! it, and shares [`AccessToken`] and [`CredentialError`] too. +//! * [`VendorTransport`]: the HTTP seam a vendor client sends through, of +//! the same shape as [`ZeroKMSConnection`](stack_kms::ZeroKMSConnection), +//! so the same vendor code runs on native Rust and in the WASI guest. +//! `HttpVendorTransport` (feature `http`, native only) is the native +//! implementation; the WASI guest's lives in `stack-guest-abi`. +//! +//! The crate changes nothing in `stack-encrypt`. A cipher uses a key vendor +//! backend through the `stack-kms` traits it already uses for ZeroKMS, and +//! seals the same v1 leaf. +//! +//! # The key mode +//! +//! This release has one key mode, **derived data key** +//! ([`KeyMode::Derived`](stack_kms::KeyMode::Derived)). The keyset record +//! holds the wrapped root key. The adapter unwraps it and keeps it for a +//! TTL ([`DEFAULT_ROOT_KEY_TTL`]). Each data key is `HKDF-SHA256(root key, +//! salt = iv, info = keyset id ‖ vendor ‖ mode ‖ descriptor)`, and the index +//! key is derived from the root key under a separate label. +//! +//! See [`derive`](mod@derive) for the exact derivations and associated data. +//! +//! # Capability matrix +//! +//! A key vendor backend works at less than ZeroKMS parity. This section +//! states each difference. It describes the code of this crate, of the +//! vendor crates (`stack-kms-aws`, `stack-kms-gcp`, `stack-kms-azure`, +//! `stack-kms-vault`) and of the Go module, whose WASI guest runs the same +//! vendor code. +//! +//! **The trade-off.** The root key is cached for the TTL, and the index key +//! for as long as the cipher keeps the keyset. Postgres holds no usable key +//! material. +//! +//! ## Key lifetime and refusal +//! +//! | | ZeroKMS | Derived data key | +//! |---|---|---| +//! | Keys in application memory | The index key of a keyset, until the cipher's bounded cache evicts it. Each data key, for one settle. | The root key of a keyset, for the root key TTL ([`DEFAULT_ROOT_KEY_TTL`], 5 minutes; set it with [`VendorKeySource::with_root_key_ttl`]). The index key of a keyset, until the cipher's bounded cache evicts it, as for ZeroKMS. Each data key, for one settle. | +//! | Key backend calls for a settle of N values in one keyset | One request for each 500 keys (`stack_kms::DEFAULT_KEYS_PER_REQ`). The index key loads once, then stays in the cache. | 0 while the root key is cached, else 1 root key unwrap. The index key loads once, then stays in the cache. | +//! | What the key backend can refuse | Each request. ZeroKMS also checks the key context (lock context) and the decryption policy of each data key. | The root key unwrap only. A running process sees a refusal when its cached root key expires: at most one TTL later. After that it can no longer seal or open a value of the keyset, but a cipher that holds the keyset's index key can still build its query terms until the cache evicts the keyset. | +//! +//! A root key unwrap is tried up to 5 times, with backoff on +//! [`Throttled`](stack_kms::BackendErrorKind::Throttled) ([`RetryOptions`]). +//! Every other failure fails the request. +//! +//! ## Backends +//! +//! | Backend ([`KeyVendor::name`](stack_kms::KeyVendor::name)) | The root key unwrap | How the associated data is bound | +//! |---|---|---| +//! | ZeroKMS (`zerokms`) | n/a | n/a | +//! | AWS KMS (`aws-kms`) | 1 `Decrypt` | Natively, as the encryption context: `cipherstash:aad` is all of the associated data; `cipherstash:purpose` and `cipherstash:keyset-id` are readable copies. | +//! | Google Cloud KMS (`gcp-kms`) | 1 `decrypt` | Natively, as `additionalAuthenticatedData`, with CRC32C checks on every field. | +//! | Azure Key Vault, RSA key (`azure-key-vault`) | 1 `unwrapkey` (RSA-OAEP-256) | By us ([`bound`]): Azure wraps the key with a digest of the associated data, and the client checks the digest after the unwrap. | +//! | Azure Managed HSM, `oct-HSM` key (`azure-key-vault`) | 1 `unwrapkey` (A256KW) | By us ([`bound`]), as for an RSA key. | +//! | Vault Transit, derived key (`vault-transit`) | 1 `decrypt` | By us ([`bound`]), and also natively: the client sends all of the associated data as the key derivation `context`. | +//! | Vault Transit, non-derived key (`vault-transit`) | 1 `decrypt` | By us ([`bound`]) only. Vault ignores `context` on a non-derived key. | +//! | Static key (`static`) | in the process | Natively, as the AES-256-GCM associated data. | +//! +//! Creating or rewrapping a record costs one wrap call (and one unwrap for +//! a rewrap). First use of a key costs one more call: with the default +//! `KeyDerivation::Detect`, the Vault client reads each Transit key once +//! per process; the Azure client reads each key once per client to learn +//! its current version and type, unless the key reference names a version +//! and `AzureKeyVaultConfig::with_algorithm` is set. +//! +//! **What the vendor sees.** A vendor sees each root key it wraps or +//! unwraps. It holds the vendor key, so it could unwrap that key anyway. No +//! vendor ever sees a data key, a plaintext value or a search term. Of the +//! associated data, AWS KMS gets all of it, with the keyset id and the +//! purpose in readable form for CloudTrail and for +//! `kms:EncryptionContext:` key policy conditions. Google Cloud KMS gets +//! all of it. Vault gets all of it as `context` for a derived key, and only +//! its digest inside the wrapped plaintext for a non-derived key. Azure +//! gets only its digest inside the wrapped plaintext: Azure does not see or +//! log the binding, so a wrapped root key moved to another record unwraps +//! at Azure and is then refused by the client as +//! [`InvalidCiphertext`](stack_kms::BackendErrorKind::InvalidCiphertext). +//! The static key backend runs in the process: nothing outside it can see, +//! refuse or audit a use of the key. +//! +//! ## ZeroKMS-only features +//! +//! A key vendor backend refuses these with +//! [`Error::ZeroKmsOnly`](stack_kms::Error::ZeroKmsOnly). It never ignores +//! them ([`ZeroKmsFeature`](stack_kms::ZeroKmsFeature)): +//! +//! * a non-empty key context: a lock context, or tags and identity claims +//! bound into the data key; +//! * a decryption policy; +//! * a non-empty unverified context. +//! +//! A cipher holds ZeroKMS and at most one key vendor backend ([`KeyRouter`]). +//! A sealed leaf names its keyset and no backend, so the router sends it to +//! the backend that holds that keyset. A value sealed under one backend +//! never opens under another, and there is no migration between backends. +//! +//! ## Credentials +//! +//! | Backend | Native Rust (`from_env`) | Go (the host supplies short-lived credentials before each request) | +//! |---|---|---| +//! | AWS KMS | Static keys (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`), then web identity (IRSA), then the ECS or EKS container endpoint, then IMDSv2. Not read: `~/.aws` config and credentials files, profiles, SSO, `credential_process`. | `AWSCredentialSource`: `AWSCredentialsFunc` over the AWS SDK's chain, or `AWSCredentialsFromEnv`. | +//! | Google Cloud KMS | `GOOGLE_OAUTH_ACCESS_TOKEN`, then a service account key file (`GOOGLE_APPLICATION_CREDENTIALS`, feature `service-account`), then the metadata server. Not supported: `authorized_user` and `external_account` files, impersonation. | `AccessTokenSource` for the Cloud KMS scope: `OAuth2AccessTokens` or `AccessTokenFunc`. | +//! | Azure Key Vault and Managed HSM | `AZURE_KEY_VAULT_ACCESS_TOKEN`, then a client secret, then workload identity (`AZURE_FEDERATED_TOKEN_FILE`), then managed identity (App Service, else IMDS). Not supported: client certificates, Azure CLI, Azure Arc, Service Fabric. | `AccessTokenSource` for the scope the client names (Key Vault or Managed HSM): `AccessTokenFunc` over azidentity. | +//! | Vault Transit | `VAULT_TOKEN`, then AppRole (`VAULT_APPROLE_ROLE_ID` and `VAULT_APPROLE_SECRET_ID`), then Kubernetes auth (`VAULT_KUBERNETES_ROLE`). | `AccessTokenSource` with an empty scope: `AccessTokenFunc` over the Vault API client. | +//! | Static key | [`StaticKeyVendor::new`] takes the key. | `StaticKey.Key`, which `NewClient` wipes. | +//! +//! The web identity, container, IMDS, metadata server, workload identity, +//! managed identity and Kubernetes sources read files or reach instance +//! metadata, so they are native only. The Go guest holds no credential +//! between requests and never signs with a long-lived key: it does no RSA +//! and reads no service account key. `stash keyset` uses the native chains, +//! except for Google Cloud, where it reads `GOOGLE_OAUTH_ACCESS_TOKEN` or the +//! metadata server and refuses a service account key file. +//! +//! ## Rotation +//! +//! * **AWS KMS.** AWS rotates the key material behind one key ARN. A stored +//! wrapped key names the material that made it, so nothing changes in the +//! records or the data. +//! * **Google Cloud KMS.** `encrypt` uses the primary version, or the +//! version a `…/cryptoKeyVersions/{n}` key reference names. Each stored +//! wrapped key holds its version (`stack_kms_gcp::wrapped_key_version`). +//! Keep a version enabled while stored keys use it. +//! * **Azure.** Each stored wrapped key holds its version and algorithm +//! (`stack_kms_azure::wrapped_key_version`). A client reads the current +//! version of an unversioned key reference once and wraps under it for +//! its life, so it sees a new version after a restart, or after a wrap +//! fails and it reads the key again. Keep a version enabled while stored +//! keys use it. +//! * **Vault Transit.** `encrypt` uses the latest key version. Each stored +//! wrapped key holds its version. `VaultTransit::rewrap` moves one wrapped +//! key to the latest version inside Vault. Do not raise the key's minimum +//! decryption version past a version that stored keys still use. +//! * **Static key.** Hold the old and the new key +//! ([`StaticKeyVendor::with_key`]), rewrap every record under the new +//! key reference, then drop the old key. +//! +//! [`KeysetRecord::rewrap`] (and `stash keyset rewrap`) wraps the root key +//! of a record again. It does not re-key stored values. A record may move +//! to any key of its backend: every data key comes from the root key. +//! +//! ## Known limits +//! +//! * **All vendors.** TypeScript (`@cipherstash/stack`) cannot use a key +//! vendor backend yet; only Rust and Go can. No SDK reads the +//! `CS_KEYSET_RECORD*` variables that `stash keyset` prints: pass the +//! record to [`KeysetRecord::parse`] or Go's `WithKeysetRecords`. +//! * **AWS KMS.** An AWS error code with no fixed mapping on a 4xx status is +//! [`AccessDenied`](stack_kms::BackendErrorKind::AccessDenied), so a +//! malformed request reads as a refusal. `LimitExceededException` is a +//! quota, not a throttle, and is not retried. +//! * **Google Cloud KMS.** The service account login (feature +//! `service-account`, a default feature) signs a JWT with the `rsa` crate, +//! which has the open advisory RUSTSEC-2023-0071 (the Marvin timing +//! attack). A build without default features has no RSA code; the Go +//! guest and `@cipherstash/auth` build it that way. Cloud KMS answers +//! `PERMISSION_DENIED` for a key the caller may not see, so a missing key +//! often reads as `AccessDenied`, not `NotFound`. The fixtures are +//! written by hand, and the live test has not run yet. +//! * **Azure.** A 400 `BadParameter` on an `unwrapkey` is +//! [`InvalidCiphertext`](stack_kms::BackendErrorKind::InvalidCiphertext), +//! because Azure gives no finer code; a `BadParameter` for another reason +//! reads the same. A key reference must be on one of the six Azure Key +//! Vault and Managed HSM DNS suffixes. A 429 waits for `Retry-After`, at +//! most 30 seconds, before the retry. The fixtures are written by hand, +//! and the live test has not run yet. +//! * **Vault Transit.** The native transport trusts the operating system's +//! certificate store only. It does not read `VAULT_CACERT` and sends no +//! mTLS client certificate, so a Vault with a private CA works only when +//! that CA is in the system store (or with your own [`VendorTransport`]; +//! in Go, the client's `WithTransport` round tripper). A 400 maps to a +//! kind by Vault's error text. Do not give the token `create` on +//! `/encrypt/`: Transit then creates a missing key. +//! * **Static key.** The key is always in memory. + +#![deny(unsafe_code)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +#![warn(clippy::mem_forget)] +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![cfg_attr(test, allow(clippy::unwrap_used))] +#![cfg_attr(test, allow(clippy::expect_used))] +#![cfg_attr(test, allow(clippy::panic))] +#![cfg_attr(test, allow(unused_results))] + +pub mod bound; +mod credential; +pub mod derive; +mod error; +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +mod http; +mod record; +mod retry; +mod router; +mod source; +mod static_key; +mod transport; +mod vendor; + +#[cfg(feature = "test-support")] +pub mod contract; +#[cfg(feature = "test-support")] +mod fake; +#[cfg(feature = "test-support")] +pub mod replay; + +pub use credential::{ + system_clock, AccessToken, CachedToken, Clock, CredentialError, CredentialKind, Expiring, + Expiry, FetchCredential, DEFAULT_REFRESH_BEFORE_EXPIRY, +}; +pub use error::RecordError; +pub use record::{KeysetRecord, RECORD_FORMAT_VERSION, RECORD_PREFIX}; +pub use retry::{retry, RetryOptions}; +pub use router::KeyRouter; +pub use source::{VendorKeySource, DEFAULT_ROOT_KEY_TTL}; +pub use static_key::StaticKeyVendor; +pub use transport::{ + classify_status, TransportError, VendorRequest, VendorResponse, VendorTransport, +}; +pub use vendor::{KeyBytes, KeyVendorBackend}; + +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +pub use http::{HttpVendorTransport, HttpVendorTransportOptions, DEFAULT_REQUEST_TIMEOUT}; + +#[cfg(feature = "test-support")] +pub use fake::{FakeVendor, FakeVendorCalls}; diff --git a/packages/stack-kms-vendor/src/record.rs b/packages/stack-kms-vendor/src/record.rs new file mode 100644 index 0000000000..80b17c8381 --- /dev/null +++ b/packages/stack-kms-vendor/src/record.rs @@ -0,0 +1,502 @@ +//! Keyset records: the customer-held description of a keyset in a key +//! vendor backend. + +use std::fmt; +use std::str::FromStr; + +use base64ct::{Base64UrlUnpadded, Encoding}; +use stack_kms::{KeyMode, KeyVendor}; +use uuid::Uuid; + +use crate::derive::record_key_aad; +use crate::error::RecordError; +use crate::vendor::{random_key, KeyBytes, KeyVendorBackend}; + +/// The text every keyset record starts with. +pub const RECORD_PREFIX: &str = "stack-keyset:"; + +/// The format version of the keyset records this build writes. A record of +/// another version is refused ([`RecordError::UnknownVersion`]). +pub const RECORD_FORMAT_VERSION: u8 = 1; + +/// The longest keyset name, in bytes. +const MAX_NAME_LEN: usize = u8::MAX as usize; + +/// The customer-held description of a keyset in a key vendor backend: its +/// id, an optional name, its backend, its key mode, the vendor key that +/// protects it, and the wrapped key. +/// +/// The wrapped key is the keyset's root key. Only the vendor key can +/// unwrap it, so a record is not secret: keep it in config or in an environment variable. +/// The wrapped key is bound to the id, the backend and the mode +/// ([`derive::record_key_aad`](crate::derive::record_key_aad)), so a record +/// edited in any of them does not unwrap. The name is a local label and is +/// not bound: rename a keyset by editing its record. +/// +/// # Text form +/// +/// [`RECORD_PREFIX`] followed by the unpadded base64url of: +/// +/// ```text +/// version (1) ‖ backend (1) ‖ mode (1) ‖ keyset id (16) +/// ‖ name length (1, 0 = no name) ‖ name +/// ‖ key ref length (2, big-endian) ‖ key ref +/// ‖ wrapped key length (2, big-endian) ‖ wrapped key +/// ``` +/// +/// The backend and mode bytes are [`KeyVendor::byte`] and +/// [`KeyMode::byte`]. +/// +/// # Creating a record +/// +/// [`KeysetRecord::create`] generates the keyset id and the key, and wraps +/// the key with the vendor. [`KeysetRecord::rewrap`] moves a record to +/// another vendor key. Both are what `stash keyset` calls. +#[derive(Clone, PartialEq, Eq)] +pub struct KeysetRecord { + id: Uuid, + name: Option, + vendor: KeyVendor, + mode: KeyMode, + key_ref: String, + wrapped_key: Vec, +} + +impl fmt::Debug for KeysetRecord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("KeysetRecord") + .field("id", &self.id) + .field("name", &self.name) + .field("vendor", &self.vendor) + .field("mode", &self.mode) + .field("key_ref", &self.key_ref) + .field("wrapped_key_len", &self.wrapped_key.len()) + .finish() + } +} + +impl KeysetRecord { + /// Create a keyset in `backend`: generate a keyset id and a root key, + /// and wrap the root key under the vendor key `key_ref`. + /// + /// One vendor call. + pub async fn create( + backend: &B, + key_ref: impl Into, + mode: KeyMode, + name: Option, + ) -> Result { + let vendor = backend.vendor(); + let key_ref = key_ref.into(); + check_key_ref(&key_ref)?; + if let Some(name) = &name { + check_name(name)?; + } + let id = Uuid::new_v4(); + let key = random_key(vendor)?; + let wrapped_key = backend + .wrap(&key_ref, &key, &record_key_aad(id, vendor, mode)) + .await?; + check_wrapped(&wrapped_key)?; + Ok(Self { + id, + name, + vendor, + mode, + key_ref, + wrapped_key, + }) + } + + /// Wrap this record's root key under a vendor key of the same backend. + /// The keyset id, name and mode stay the same. Stored values are not + /// re-keyed. + /// + /// `key_ref` may name any key of the backend. Every data key comes from + /// the root key, so every value sealed under the keyset still opens. + /// See the [capability matrix](crate#rotation). + /// + /// Two vendor calls: unwrap under the old key, wrap under the new one. + pub async fn rewrap( + &self, + backend: &B, + key_ref: impl Into, + ) -> Result { + self.check_vendor(backend.vendor())?; + let key_ref = key_ref.into(); + check_key_ref(&key_ref)?; + let aad = self.key_aad(); + let key = self.unwrap_key(backend).await?; + let wrapped_key = backend.wrap(&key_ref, &key, &aad).await?; + check_wrapped(&wrapped_key)?; + Ok(Self { + key_ref, + wrapped_key, + ..self.clone() + }) + } + + /// Build a record from its parts, as a vendor crate's own tests or a + /// migration might. Prefer [`create`](Self::create). + pub fn from_parts( + id: Uuid, + name: Option, + vendor: KeyVendor, + mode: KeyMode, + key_ref: String, + wrapped_key: Vec, + ) -> Result { + if let Some(name) = &name { + check_name(name)?; + } + check_key_ref(&key_ref)?; + check_wrapped(&wrapped_key)?; + Ok(Self { + id, + name, + vendor, + mode, + key_ref, + wrapped_key, + }) + } + + /// Parse a record from its text form. Refuses an unknown version, + /// backend or mode, and a record whose length does not match its + /// fields. + pub fn parse(text: &str) -> Result { + let body = text + .trim() + .strip_prefix(RECORD_PREFIX) + .ok_or(RecordError::NotARecord)?; + let bytes = Base64UrlUnpadded::decode_vec(body).map_err(|_| RecordError::Encoding)?; + Self::from_bytes(&bytes) + } + + fn from_bytes(bytes: &[u8]) -> Result { + let mut reader = Reader(bytes); + let version = reader.u8()?; + if version != RECORD_FORMAT_VERSION { + return Err(RecordError::UnknownVersion(version)); + } + let vendor_byte = reader.u8()?; + let vendor = + KeyVendor::from_byte(vendor_byte).ok_or(RecordError::UnknownBackend(vendor_byte))?; + let mode_byte = reader.u8()?; + let mode = KeyMode::from_byte(mode_byte).ok_or(RecordError::UnknownMode(mode_byte))?; + let id = Uuid::from_bytes(reader.array::<16>()?); + let name_len = usize::from(reader.u8()?); + let name = match name_len { + 0 => None, + len => Some(utf8(reader.take(len)?, "name")?), + }; + let key_ref_len = usize::from(reader.u16()?); + let key_ref = utf8(reader.take(key_ref_len)?, "key_ref")?; + let wrapped_len = usize::from(reader.u16()?); + let wrapped_key = reader.take(wrapped_len)?.to_vec(); + if !reader.0.is_empty() { + return Err(RecordError::Length); + } + Self::from_parts(id, name, vendor, mode, key_ref, wrapped_key) + } + + /// The binary form, before base64url. + fn to_bytes(&self) -> Vec { + let name = self.name.as_deref().unwrap_or("").as_bytes(); + let mut out = Vec::with_capacity( + 3 + 16 + 1 + name.len() + 2 + self.key_ref.len() + 2 + self.wrapped_key.len(), + ); + out.extend_from_slice(&[RECORD_FORMAT_VERSION, self.vendor.byte(), self.mode.byte()]); + out.extend_from_slice(self.id.as_bytes()); + // The lengths were checked at construction, so the casts are exact. + out.push(name.len() as u8); + out.extend_from_slice(name); + out.extend_from_slice(&(self.key_ref.len() as u16).to_be_bytes()); + out.extend_from_slice(self.key_ref.as_bytes()); + out.extend_from_slice(&(self.wrapped_key.len() as u16).to_be_bytes()); + out.extend_from_slice(&self.wrapped_key); + out + } + + /// The keyset id. + pub fn id(&self) -> Uuid { + self.id + } + + /// The keyset name, if it has one. + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// The key vendor backend that holds the keyset. + pub fn vendor(&self) -> KeyVendor { + self.vendor + } + + /// The key mode of the keyset. + pub fn mode(&self) -> KeyMode { + self.mode + } + + /// The versioned vendor key reference. + pub fn key_ref(&self) -> &str { + &self.key_ref + } + + /// The wrapped root key. + pub fn wrapped_key(&self) -> &[u8] { + &self.wrapped_key + } + + /// The associated data the wrapped key is bound to. + pub(crate) fn key_aad(&self) -> Vec { + record_key_aad(self.id, self.vendor, self.mode) + } + + /// Unwrap this record's key with `backend`. + pub(crate) async fn unwrap_key( + &self, + backend: &B, + ) -> Result { + backend + .unwrap(&self.key_ref, &self.wrapped_key, &self.key_aad()) + .await + } + + pub(crate) fn check_vendor(&self, expected: KeyVendor) -> Result<(), RecordError> { + if self.vendor == expected { + Ok(()) + } else { + Err(RecordError::ForeignVendor { + keyset: self.id, + expected, + found: self.vendor, + }) + } + } +} + +/// The text form: [`RECORD_PREFIX`] and the unpadded base64url of the +/// binary form. +impl fmt::Display for KeysetRecord { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(RECORD_PREFIX)?; + f.write_str(&Base64UrlUnpadded::encode_string(&self.to_bytes())) + } +} + +impl FromStr for KeysetRecord { + type Err = RecordError; + + fn from_str(text: &str) -> Result { + Self::parse(text) + } +} + +fn check_name(name: &str) -> Result<(), RecordError> { + if name.is_empty() || name.len() > MAX_NAME_LEN { + return Err(RecordError::InvalidField { field: "name" }); + } + Ok(()) +} + +fn check_key_ref(key_ref: &str) -> Result<(), RecordError> { + if key_ref.is_empty() || key_ref.len() > usize::from(u16::MAX) { + return Err(RecordError::InvalidField { field: "key_ref" }); + } + Ok(()) +} + +fn check_wrapped(wrapped: &[u8]) -> Result<(), RecordError> { + if wrapped.is_empty() || wrapped.len() > usize::from(u16::MAX) { + return Err(RecordError::InvalidField { + field: "wrapped_key", + }); + } + Ok(()) +} + +fn utf8(bytes: &[u8], field: &'static str) -> Result { + String::from_utf8(bytes.to_vec()).map_err(|_| RecordError::InvalidField { field }) +} + +/// A cursor over the binary form. Every read past the end is +/// [`RecordError::Length`]. +struct Reader<'a>(&'a [u8]); + +impl<'a> Reader<'a> { + fn take(&mut self, len: usize) -> Result<&'a [u8], RecordError> { + if self.0.len() < len { + return Err(RecordError::Length); + } + let (head, rest) = self.0.split_at(len); + self.0 = rest; + Ok(head) + } + + fn array(&mut self) -> Result<[u8; N], RecordError> { + let mut out = [0u8; N]; + out.copy_from_slice(self.take(N)?); + Ok(out) + } + + fn u8(&mut self) -> Result { + Ok(self.array::<1>()?[0]) + } + + fn u16(&mut self) -> Result { + Ok(u16::from_be_bytes(self.array::<2>()?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record() -> KeysetRecord { + KeysetRecord::from_parts( + Uuid::from_u128(0x0102_0304_0506_0708_090a_0b0c_0d0e_0f10), + Some("acme".into()), + KeyVendor::Aws, + KeyMode::Derived, + "arn:aws:kms:us-east-1:111122223333:key/k".into(), + vec![1, 2, 3], + ) + .unwrap() + } + + #[test] + fn a_record_round_trips_through_its_text_form() { + let record = record(); + let text = record.to_string(); + assert!(text.starts_with(RECORD_PREFIX)); + assert_eq!(KeysetRecord::parse(&text).unwrap(), record); + assert_eq!(text.parse::().unwrap(), record); + // Whitespace around a value pasted into an env var is not an error. + assert_eq!(KeysetRecord::parse(&format!(" {text}\n")).unwrap(), record); + + let unnamed = KeysetRecord { + name: None, + ..record + }; + assert_eq!(KeysetRecord::parse(&unnamed.to_string()).unwrap(), unnamed); + } + + /// The text form is a storage commitment: this pin fails if it moves. + #[test] + fn the_text_form_is_pinned() { + assert_eq!(record().to_string(), "stack-keyset:AQECAQIDBAUGBwgJCgsMDQ4PEARhY21lAChhcm46YXdzOmttczp1cy1lYXN0LTE6MTExMTIyMjIzMzMzOmtleS9rAAMBAgM"); + } + + fn reencode(bytes: &[u8]) -> String { + format!("{RECORD_PREFIX}{}", Base64UrlUnpadded::encode_string(bytes)) + } + + #[test] + fn parsing_refuses_what_it_does_not_know() { + let bytes = record().to_bytes(); + let refuse = |edit: &dyn Fn(&mut Vec)| { + let mut bytes = bytes.clone(); + edit(&mut bytes); + KeysetRecord::parse(&reencode(&bytes)).unwrap_err() + }; + assert!(matches!( + refuse(&|b| b[0] = 2), + RecordError::UnknownVersion(2) + )); + assert!(matches!( + refuse(&|b| b[0] = 0), + RecordError::UnknownVersion(0) + )); + assert!(matches!( + refuse(&|b| b[1] = 0), + RecordError::UnknownBackend(0) + )); + assert!(matches!( + refuse(&|b| b[1] = 99), + RecordError::UnknownBackend(99) + )); + assert!(matches!(refuse(&|b| b[2] = 3), RecordError::UnknownMode(3))); + assert!(matches!(refuse(&|b| b[2] = 0), RecordError::UnknownMode(0))); + assert!(matches!(refuse(&|b| b[2] = 1), RecordError::UnknownMode(1))); + assert!(matches!(refuse(&|b| b.push(0)), RecordError::Length)); + assert!(matches!( + refuse(&|b| { + let _ = b.pop(); + }), + RecordError::Length + )); + assert!(matches!(refuse(&|b| b.truncate(10)), RecordError::Length)); + assert!(matches!( + KeysetRecord::parse("stack-keyset:***"), + Err(RecordError::Encoding) + )); + assert!(matches!( + KeysetRecord::parse("not-a-record"), + Err(RecordError::NotARecord) + )); + assert!(matches!( + KeysetRecord::parse(""), + Err(RecordError::NotARecord) + )); + } + + #[test] + fn fields_are_checked() { + let base = record(); + let bad_name = KeysetRecord::from_parts( + base.id, + Some(String::new()), + base.vendor, + base.mode, + base.key_ref.clone(), + base.wrapped_key.clone(), + ); + assert!(matches!( + bad_name, + Err(RecordError::InvalidField { field: "name" }) + )); + let long_name = KeysetRecord::from_parts( + base.id, + Some("n".repeat(256)), + base.vendor, + base.mode, + base.key_ref.clone(), + base.wrapped_key.clone(), + ); + assert!(long_name.is_err()); + assert!(KeysetRecord::from_parts( + base.id, + Some("n".repeat(255)), + base.vendor, + base.mode, + base.key_ref.clone(), + base.wrapped_key.clone(), + ) + .is_ok()); + assert!(matches!( + KeysetRecord::from_parts( + base.id, + None, + base.vendor, + base.mode, + String::new(), + vec![1] + ), + Err(RecordError::InvalidField { field: "key_ref" }) + )); + assert!(matches!( + KeysetRecord::from_parts(base.id, None, base.vendor, base.mode, "k".into(), vec![]), + Err(RecordError::InvalidField { + field: "wrapped_key" + }) + )); + } + + #[test] + fn debug_shows_no_wrapped_key() { + let shown = format!("{:?}", record()); + assert!(shown.contains("wrapped_key_len: 3"), "{shown}"); + assert!(!shown.contains("[1, 2, 3]"), "{shown}"); + } +} diff --git a/packages/stack-kms-vendor/src/replay.rs b/packages/stack-kms-vendor/src/replay.rs new file mode 100644 index 0000000000..31efdcfdc9 --- /dev/null +++ b/packages/stack-kms-vendor/src/replay.rs @@ -0,0 +1,272 @@ +//! Recorded HTTP exchanges for the tests of a key vendor backend. +//! +//! Every vendor crate tests its client against fixture files: JSON that +//! holds one exchange ([`Fixture`]) or a sequence of them. This module is +//! the one copy of the harness that reads them: +//! +//! * [`Fixtures`] finds and parses the files of one crate; +//! * [`Replay`] is a [`VendorTransport`] that checks each request against a +//! fixture and answers with the fixture's response; +//! * [`Recording`] is a [`VendorTransport`] that sends through another one +//! and keeps every exchange, to write fixtures from a live service. +//! +//! The vendor-specific parts (emulators, keys, constants) stay in each +//! crate's `tests/common/mod.rs`. +//! +//! ```ignore +//! use stack_kms_vendor::replay::{Fixtures, Replay}; +//! +//! const FIXTURES: Fixtures = +//! Fixtures::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")); +//! +//! let transport = Replay::new().expect(FIXTURES.load("encrypt.json")); +//! ``` + +// The harness is test code: it panics when a request differs from its +// fixture or a fixture file does not parse. +#![allow(clippy::panic, clippy::unwrap_used, clippy::expect_used)] + +use std::collections::VecDeque; +use std::sync::Mutex; +use std::time::Duration; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::{TransportError, VendorRequest, VendorResponse, VendorTransport}; + +/// The request half of a recorded exchange. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct FixtureRequest { + /// The HTTP method. + pub method: String, + /// The full URL. + pub url: String, + /// Every header, in the order it is sent. + pub headers: Vec<(String, String)>, + /// The body as JSON; see [`body_json`]. + pub body: Value, +} + +/// The response half of a recorded exchange. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct FixtureResponse { + /// The HTTP status. + pub status: u16, + /// The headers. Missing means none. + #[serde(default)] + pub headers: Vec<(String, String)>, + /// The body: a JSON string is sent as its text, `null` as no bytes, + /// and any other value as its JSON form. + pub body: Value, +} + +impl FixtureResponse { + /// The response this fixture describes. + pub fn to_response(&self) -> VendorResponse { + let body = match &self.body { + Value::String(text) => text.clone().into_bytes(), + Value::Null => Vec::new(), + other => serde_json::to_vec(other).unwrap(), + }; + let mut response = VendorResponse::new(self.status, body); + for (name, value) in &self.headers { + response = response.header(name, value); + } + response + } +} + +/// One recorded exchange: a request and the response to it. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Fixture { + /// The request the client must send. + pub request: FixtureRequest, + /// The response the vendor gave. + pub response: FixtureResponse, +} + +/// The fixture directory of one crate. +#[derive(Clone, Copy, Debug)] +pub struct Fixtures { + dir: &'static str, +} + +impl Fixtures { + /// The fixtures in `dir`. Give an absolute path, such as + /// `concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")`, so the + /// tests do not depend on the working directory. + pub const fn new(dir: &'static str) -> Self { + Self { dir } + } + + /// The path of the fixture file `name`. + pub fn path(&self, name: &str) -> String { + format!("{}/{name}", self.dir) + } + + /// Parse the fixture file `name`. Panics with the path when the file + /// cannot be read or parsed. + pub fn load(&self, name: &str) -> T { + let path = self.path(name); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}")); + serde_json::from_str(&text).unwrap_or_else(|e| panic!("{path}: {e}")) + } + + /// A [`Replay`] of the sequence of exchanges in the file `name`. + pub fn replay(&self, name: &str) -> Replay { + Replay::new().expect_all(self.load(name)) + } +} + +/// A body as JSON, the form fixtures keep it in: no bytes is `null`, JSON +/// is its value, and any other bytes are a JSON string of their text. +pub fn body_json(body: &[u8]) -> Value { + if body.is_empty() { + Value::Null + } else { + serde_json::from_slice(body) + .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(body).into())) + } +} + +/// A transport that checks each request against a recorded one and answers +/// with the recorded response. It panics on a request the fixtures do not +/// expect, and keeps the waits the client asks for instead of waiting. +#[derive(Debug, Default)] +pub struct Replay { + exchanges: Mutex, VendorResponse)>>, + sleeps: Mutex>, +} + +impl Replay { + /// A replay that expects no requests. + pub fn new() -> Self { + Self::default() + } + + /// Expect `fixture`'s request and answer with its response. + pub fn expect(self, fixture: Fixture) -> Self { + let response = fixture.response.to_response(); + self.exchanges + .lock() + .unwrap() + .push_back((Some(fixture.request), response)); + self + } + + /// Expect each exchange of a recorded sequence, in order. + pub fn expect_all(mut self, fixtures: Vec) -> Self { + for fixture in fixtures { + self = self.expect(fixture); + } + self + } + + /// Answer the next request, whatever it is, with `response`. + pub fn answer(self, response: VendorResponse) -> Self { + self.exchanges.lock().unwrap().push_back((None, response)); + self + } + + /// Whether every expected request was sent. + pub fn is_done(&self) -> bool { + self.exchanges.lock().unwrap().is_empty() + } + + /// The waits the client asked for, in order. + pub fn sleeps(&self) -> Vec { + self.sleeps.lock().unwrap().clone() + } +} + +impl VendorTransport for Replay { + async fn send(&self, request: VendorRequest) -> Result { + let (expected, response) = self + .exchanges + .lock() + .unwrap() + .pop_front() + .expect("a request the fixtures do not expect"); + if let Some(expected) = expected { + let actual = json!({ + "method": request.method, + "url": request.url, + "headers": request.headers, + "body": body_json(&request.body), + }); + let wanted = json!({ + "method": expected.method, + "url": expected.url, + "headers": expected.headers, + "body": expected.body, + }); + assert_eq!( + actual, + wanted, + "the request differs from the fixture; actual:\n{}", + serde_json::to_string_pretty(&actual).unwrap() + ); + } + Ok(response) + } + + async fn sleep(&self, duration: Duration) { + self.sleeps.lock().unwrap().push(duration); + } +} + +/// A transport that sends through `inner` and keeps every exchange, to +/// write fixtures. It keeps only the `content-type` header of a response. +#[derive(Debug)] +pub struct Recording { + /// The transport that sends. + pub inner: T, + /// The exchanges so far. + pub exchanges: Mutex>, +} + +impl Recording { + /// A recording over `inner`, with no exchanges. + pub fn new(inner: T) -> Self { + Self { + inner, + exchanges: Mutex::new(Vec::new()), + } + } + + /// The exchanges so far, and forget them. + pub fn take(&self) -> Vec { + std::mem::take(&mut *self.exchanges.lock().unwrap()) + } +} + +impl VendorTransport for Recording { + async fn send(&self, request: VendorRequest) -> Result { + let recorded = FixtureRequest { + method: request.method.clone(), + url: request.url.clone(), + headers: request.headers.clone(), + body: body_json(&request.body), + }; + let response = self.inner.send(request).await?; + let headers = response + .header_value("content-type") + .map(|value| vec![("content-type".to_owned(), value.to_owned())]) + .unwrap_or_default(); + self.exchanges.lock().unwrap().push(Fixture { + request: recorded, + response: FixtureResponse { + status: response.status, + headers, + body: body_json(&response.body), + }, + }); + Ok(response) + } + + async fn sleep(&self, duration: Duration) { + self.inner.sleep(duration).await; + } +} diff --git a/packages/stack-kms-vendor/src/retry.rs b/packages/stack-kms-vendor/src/retry.rs new file mode 100644 index 0000000000..4ecfb23d17 --- /dev/null +++ b/packages/stack-kms-vendor/src/retry.rs @@ -0,0 +1,187 @@ +//! One vendor call, with backoff on a throttle. + +use std::future::Future; +use std::time::Duration; + +use stack_kms::{BackendErrorKind, Error}; + +/// How [`retry`] tries a vendor call again after a throttle. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RetryOptions { + /// The most attempts for one call, the first one included. A call is + /// tried again only after a `Throttled` failure. Default 5. + pub max_attempts: u32, + /// The wait before the second attempt. Each later wait is double the + /// one before it, up to `max_delay`. Default 50 ms. + pub base_delay: Duration, + /// The longest wait between two attempts. Default 2 s. + pub max_delay: Duration, +} + +impl Default for RetryOptions { + fn default() -> Self { + Self { + max_attempts: 5, + base_delay: Duration::from_millis(50), + max_delay: Duration::from_secs(2), + } + } +} + +impl RetryOptions { + /// The wait after the failed attempt number `attempt` (1-based). + fn delay_after(&self, attempt: u32) -> Duration { + let factor = 1u32 + .checked_shl(attempt.saturating_sub(1)) + .unwrap_or(u32::MAX); + self.base_delay + .checked_mul(factor) + .map_or(self.max_delay, |delay| delay.min(self.max_delay)) + } +} + +fn is_throttled(error: &Error) -> bool { + matches!( + error, + Error::Backend { + kind: BackendErrorKind::Throttled, + .. + } + ) +} + +/// Run `call`, and run it again after `sleep(delay)` each time it fails +/// with [`Throttled`](stack_kms::BackendErrorKind::Throttled), with an +/// exponential delay, up to [`max_attempts`](RetryOptions::max_attempts) +/// in all. Any other failure, or a call that is still throttled after the +/// last attempt, is returned as it is. +/// +/// Runtime-agnostic: it uses no timer of its own. `sleep` is the caller's +/// timer ([`KeyVendorBackend::sleep`](crate::KeyVendorBackend::sleep)). +pub async fn retry( + options: &RetryOptions, + call: F, + sleep: S, +) -> Result +where + F: Fn() -> Fut, + Fut: Future>, + S: Fn(Duration) -> SFut, + SFut: Future, +{ + let mut attempt = 1; + loop { + match call().await { + Err(error) if is_throttled(&error) && attempt < options.max_attempts => { + sleep(options.delay_after(attempt)).await; + attempt += 1; + } + result => return result, + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; + + use super::*; + + fn throttled() -> Error { + Error::Backend { + backend: stack_kms::KeyVendor::Static.into(), + kind: BackendErrorKind::Throttled, + source: "slow down".into(), + } + } + + #[tokio::test] + async fn a_throttled_call_backs_off_and_is_tried_again() { + let attempts = AtomicUsize::new(0); + let waits = Mutex::new(Vec::new()); + let result = retry( + &RetryOptions::default(), + || async { + if attempts.fetch_add(1, Ordering::SeqCst) < 3 { + Err(throttled()) + } else { + Ok("done") + } + }, + |delay| { + waits.lock().unwrap().push(delay); + async {} + }, + ) + .await + .unwrap(); + assert_eq!(result, "done"); + assert_eq!( + *waits.lock().unwrap(), + [ + Duration::from_millis(50), + Duration::from_millis(100), + Duration::from_millis(200) + ] + ); + } + + #[tokio::test] + async fn still_throttled_after_the_last_attempt_fails() { + let attempts = AtomicUsize::new(0); + let result = retry( + &RetryOptions::default(), + || async { + let _ = attempts.fetch_add(1, Ordering::SeqCst); + Err::<(), _>(throttled()) + }, + |_| async {}, + ) + .await; + assert!(matches!( + result, + Err(Error::Backend { + kind: BackendErrorKind::Throttled, + .. + }) + )); + assert_eq!(attempts.load(Ordering::SeqCst), 5); + } + + #[tokio::test] + async fn any_other_failure_is_not_tried_again() { + let attempts = AtomicUsize::new(0); + let result = retry( + &RetryOptions::default(), + || async { + let _ = attempts.fetch_add(1, Ordering::SeqCst); + Err::<(), _>(Error::Backend { + backend: stack_kms::KeyVendor::Static.into(), + kind: BackendErrorKind::AccessDenied, + source: "no".into(), + }) + }, + |_| async {}, + ) + .await; + assert!(matches!( + result, + Err(Error::Backend { + kind: BackendErrorKind::AccessDenied, + .. + }) + )); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + } + + #[test] + fn the_delay_doubles_up_to_the_cap() { + let options = RetryOptions::default(); + assert_eq!(options.delay_after(1), Duration::from_millis(50)); + assert_eq!(options.delay_after(2), Duration::from_millis(100)); + assert_eq!(options.delay_after(6), Duration::from_millis(1600)); + assert_eq!(options.delay_after(7), Duration::from_secs(2)); + assert_eq!(options.delay_after(40), Duration::from_secs(2)); + } +} diff --git a/packages/stack-kms-vendor/src/router.rs b/packages/stack-kms-vendor/src/router.rs new file mode 100644 index 0000000000..b4d13a5eab --- /dev/null +++ b/packages/stack-kms-vendor/src/router.rs @@ -0,0 +1,209 @@ +//! ZeroKMS and at most one key vendor backend in one key source. + +use std::borrow::Cow; + +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, Error, GenerateKeyPayload, IdentifiedBy, IndexKey, + IndexKeySource, RetrieveKeyPayload, UnverifiedContext, ZeroKmsKeySource, +}; +use uuid::Uuid; + +use crate::source::VendorKeySource; +use crate::vendor::KeyVendorBackend; + +/// ZeroKMS and at most one key vendor backend, as one key source that +/// routes each request by its keyset (ADR-0013). +/// +/// A cipher holds ZeroKMS and **at most one** key vendor backend. The type +/// has room for one or none: [`new`](Self::new) gives it one, and +/// [`zerokms_only`](Self::zerokms_only) none. The ZeroKMS side must be a +/// [`ZeroKmsKeySource`], which a router is not, so a router cannot hold a +/// second router and its vendor: +/// +/// ```compile_fail +/// use stack_kms::FakeDataKeySource; +/// use stack_kms_vendor::{FakeVendor, KeyRouter, VendorKeySource}; +/// +/// fn nested( +/// inner: KeyRouter, +/// vendor: VendorKeySource, +/// ) { +/// // A router is not a ZeroKmsKeySource. +/// let _ = KeyRouter::new(inner, vendor); +/// } +/// ``` +/// +/// [`VendorKeySource::new`] refuses records that name a second vendor +/// ([`RecordError::SecondVendor`](crate::RecordError::SecondVendor)). +/// +/// Routing: +/// +/// * A keyset id that one of the vendor's records names goes to the vendor. +/// Every other id goes to ZeroKMS. +/// * A keyset name that one of the vendor's records has goes to the vendor. +/// A local record name wins over a ZeroKMS keyset of the same name. Every +/// other name goes to ZeroKMS. +/// * Naming no keyset is ZeroKMS's default keyset, or the vendor's default +/// keyset after [`with_vendor_default`](Self::with_vendor_default). +/// * With no vendor, every request goes to ZeroKMS. +/// +/// A sealed leaf names its keyset id and no key backend, so the router is +/// what sends a leaf to the backend that sealed it. A leaf of a keyset that +/// no loaded record names goes to ZeroKMS, which answers that it does not +/// know the keyset. A record whose vendor or mode was edited derives other +/// keys, so its values fail authentication; they never open under the +/// wrong keys. There is no migration between the two backends in either +/// direction. +pub struct KeyRouter { + zerokms: Z, + vendor: Option>, + vendor_default: bool, +} + +impl std::fmt::Debug for KeyRouter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KeyRouter") + .field("zerokms", &std::any::type_name::()) + .field("vendor", &self.vendor) + .field("vendor_default", &self.vendor_default) + .finish() + } +} + +impl KeyRouter { + /// Route between `zerokms` and `vendor`. + pub fn new(zerokms: Z, vendor: VendorKeySource) -> Self { + Self { + zerokms, + vendor: Some(vendor), + vendor_default: false, + } + } + + /// Send every request to `zerokms`: a client with no key vendor + /// backend. + pub fn zerokms_only(zerokms: Z) -> Self { + Self { + zerokms, + vendor: None, + vendor_default: false, + } + } +} + +impl KeyRouter { + /// Make naming no keyset select the vendor's default keyset (its only + /// record, or the one set with + /// [`VendorKeySource::with_default_keyset`]), not ZeroKMS's. For a + /// client that has a key vendor backend and no ZeroKMS credentials: its + /// ZeroKMS side is then a source that refuses every request. A router + /// with no vendor ([`zerokms_only`](Self::zerokms_only)) ignores it. + pub fn with_vendor_default(mut self) -> Self { + self.vendor_default = true; + self + } + + /// The ZeroKMS key source. + pub fn zerokms(&self) -> &Z { + &self.zerokms + } + + /// The key vendor backend's key source, or `None` when the router has + /// no vendor. + pub fn vendor(&self) -> Option<&VendorKeySource> { + self.vendor.as_ref() + } + + /// The vendor, when it is the default. + fn default_vendor(&self) -> Option<&VendorKeySource> { + self.vendor.as_ref().filter(|_| self.vendor_default) + } + + /// The vendor, when one of its records names `keyset`. + fn vendor_with_keyset(&self, keyset: Uuid) -> Option<&VendorKeySource> { + self.vendor + .as_ref() + .filter(|vendor| vendor.has_keyset(keyset)) + } + + /// The vendor, when `keyset` routes to it. + fn vendor_for_id(&self, keyset: Option) -> Option<&VendorKeySource> { + match keyset { + None => self.default_vendor(), + Some(keyset) => self.vendor_with_keyset(keyset), + } + } + + /// The vendor, when the selection `keyset` routes to it. + fn vendor_for_selection(&self, keyset: Option<&IdentifiedBy>) -> Option<&VendorKeySource> { + match keyset { + None => self.default_vendor(), + Some(IdentifiedBy::Uuid(id)) => self.vendor_with_keyset(*id), + Some(IdentifiedBy::Name(name)) => { + self.vendor.as_ref().filter(|vendor| vendor.has_name(name)) + } + } + } +} + +impl DataKeySource for KeyRouter +where + Z: DataKeySource + Send + Sync, + B: KeyVendorBackend, +{ + async fn generate_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option>, + ) -> Result, Error> { + match self.vendor_for_id(keyset_id) { + Some(vendor) => { + vendor + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + None => { + self.zerokms + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + } + } + + async fn retrieve_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option<&UnverifiedContext>, + ) -> Result, Error> { + match self.vendor_for_id(keyset_id) { + Some(vendor) => { + vendor + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } + None => { + self.zerokms + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } + } + } +} + +impl IndexKeySource for KeyRouter +where + Z: IndexKeySource + Send + Sync, + B: KeyVendorBackend, +{ + async fn load_index_key( + &self, + keyset_id: Option, + ) -> Result<(Uuid, IndexKey), Error> { + match self.vendor_for_selection(keyset_id.as_ref()) { + Some(vendor) => vendor.load_index_key(keyset_id).await, + None => self.zerokms.load_index_key(keyset_id).await, + } + } +} diff --git a/packages/stack-kms-vendor/src/source.rs b/packages/stack-kms-vendor/src/source.rs new file mode 100644 index 0000000000..2ff2203b0d --- /dev/null +++ b/packages/stack-kms-vendor/src/source.rs @@ -0,0 +1,369 @@ +//! The adapter from one key vendor backend and its keyset records to the +//! stack-kms key source traits. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::{Mutex, PoisonError}; +use std::time::Duration; + +use stack_kms::{ + refuse_zerokms_extensions, BackendErrorKind, DataKey, DataKeySource, DataKeyWithTag, Error, + GenerateKeyPayload, IdentifiedBy, IndexKey, IndexKeySource, KeyVendor, RetrieveKeyPayload, + UnverifiedContext, +}; +use uuid::Uuid; +use vitaminc::protected::{Controlled, Protected}; +use web_time::Instant; + +use crate::derive::{derived_data_key, derived_index_key}; +use crate::error::RecordError; +use crate::record::KeysetRecord; +use crate::retry::{retry, RetryOptions}; +use crate::vendor::{random_bytes, KeyBytes, KeyVendorBackend}; + +/// How long the adapter keeps an unwrapped root key before it asks the +/// vendor again: five minutes. +pub const DEFAULT_ROOT_KEY_TTL: Duration = Duration::from_secs(5 * 60); + +/// A root key the adapter holds, and when it must drop it. +struct CachedRoot { + key: KeyBytes, + until: Instant, +} + +/// One key vendor backend and its keyset records, as a +/// [`DataKeySource`] and an [`IndexKeySource`]. +/// +/// This is the adapter every vendor shares. A vendor crate implements +/// [`KeyVendorBackend`]; this type builds the derived data key mode on it. +/// The adapter unwraps a keyset's root key (one vendor call), keeps it for +/// the root key TTL ([`DEFAULT_ROOT_KEY_TTL`]), and derives every data key +/// and the index key from it with no vendor call. The root key is wiped +/// when it expires or when the adapter drops. After the TTL the next +/// request unwraps it again, so the vendor sees, and can refuse, one call +/// per keyset per TTL. +/// +/// The TTL covers the root key and the data keys. It does not cover the +/// index key: a cipher keeps the index key it loaded until its keyset cache +/// evicts it, as it does for a ZeroKMS keyset. A cipher that already holds +/// a keyset's index key can thus still build query terms for that keyset +/// after the vendor refuses it, but it cannot seal or open a value. See +/// the [capability matrix](crate#capability-matrix). +/// +/// Keyset names resolve locally from the loaded records, with no freshness +/// window: a record is the customer's statement of what the name means. +/// +/// The adapter refuses every ZeroKMS-only input (a lock context, a +/// decryption policy, an unverified context) with +/// [`Error::ZeroKmsOnly`]. +pub struct VendorKeySource { + backend: B, + records: HashMap, + names: HashMap, + default: Option, + root_ttl: Duration, + retry: RetryOptions, + roots: Mutex>, +} + +impl std::fmt::Debug for VendorKeySource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VendorKeySource") + .field("keysets", &self.records.keys().collect::>()) + .field("default", &self.default) + .field("root_ttl", &self.root_ttl) + .finish_non_exhaustive() + } +} + +/// The one vendor a set of records names, or `None` for no records. +/// Two vendors is [`RecordError::SecondVendor`]: a cipher holds ZeroKMS and +/// at most one key vendor backend. +pub(crate) fn vendor_of<'a>( + records: impl IntoIterator, +) -> Result, RecordError> { + let mut found: Option = None; + for record in records { + match found { + None => found = Some(record.vendor()), + Some(first) if first != record.vendor() => { + return Err(RecordError::SecondVendor { + first, + second: record.vendor(), + }) + } + Some(_) => {} + } + } + Ok(found) +} + +fn not_found(vendor: KeyVendor) -> Error { + Error::Backend { + backend: vendor.into(), + kind: BackendErrorKind::NotFound, + source: "no keyset record for this keyset".into(), + } +} + +fn invalid(vendor: KeyVendor, message: &'static str) -> Error { + Error::Backend { + backend: vendor.into(), + kind: BackendErrorKind::InvalidCiphertext, + source: message.into(), + } +} + +impl VendorKeySource { + /// The adapter over `backend` and `records`. + /// + /// Refuses records that name two vendors ([`RecordError::SecondVendor`]), + /// a record of another vendor than `backend` + /// ([`RecordError::ForeignVendor`]), and two records with the same id + /// or name. + /// + /// The default keyset (naming no keyset) is the only record when there + /// is exactly one, else none until + /// [`with_default_keyset`](Self::with_default_keyset) sets it. + pub fn new( + backend: B, + records: impl IntoIterator, + ) -> Result { + let records: Vec = records.into_iter().collect(); + let vendor = backend.vendor(); + let _ = vendor_of(&records)?; + let mut by_id = HashMap::with_capacity(records.len()); + let mut names = HashMap::new(); + for record in records { + record.check_vendor(vendor)?; + if let Some(name) = record.name() { + if names.insert(name.to_owned(), record.id()).is_some() { + return Err(RecordError::DuplicateName); + } + } + let id = record.id(); + if by_id.insert(id, record).is_some() { + return Err(RecordError::DuplicateKeyset(id)); + } + } + let default = match by_id.len() { + 1 => by_id.keys().next().copied(), + _ => None, + }; + Ok(Self { + backend, + records: by_id, + names, + default, + root_ttl: DEFAULT_ROOT_KEY_TTL, + retry: RetryOptions::default(), + roots: Mutex::new(HashMap::new()), + }) + } + + /// Parse each text record and build the adapter, as [`new`](Self::new) + /// does. For records held in config or environment variables. + pub fn from_texts<'t>( + backend: B, + texts: impl IntoIterator, + ) -> Result { + let records = texts + .into_iter() + .map(KeysetRecord::parse) + .collect::, _>>()?; + Self::new(backend, records) + } + + /// Make `keyset` the default keyset: the one naming no keyset selects. + pub fn with_default_keyset(mut self, keyset: Uuid) -> Result { + if !self.records.contains_key(&keyset) { + return Err(RecordError::UnknownDefault); + } + self.default = Some(keyset); + Ok(self) + } + + /// How long to keep an unwrapped root key. Default + /// [`DEFAULT_ROOT_KEY_TTL`]. `Duration::ZERO` keeps none: every request + /// unwraps again. + pub fn with_root_key_ttl(mut self, ttl: Duration) -> Self { + self.root_ttl = ttl; + self + } + + /// How a throttled vendor call is tried again. + pub fn with_retry_options(mut self, options: RetryOptions) -> Self { + self.retry = options; + self + } + + /// The key vendor backend. + pub fn backend(&self) -> &B { + &self.backend + } + + /// Whether a record for `keyset` is loaded. + pub fn has_keyset(&self, keyset: Uuid) -> bool { + self.records.contains_key(&keyset) + } + + /// Whether a loaded record has the name `name`. + pub fn has_name(&self, name: &str) -> bool { + self.names.contains_key(name) + } + + /// The record of `keyset`, if it is loaded. + pub fn record(&self, keyset: Uuid) -> Option<&KeysetRecord> { + self.records.get(&keyset) + } + + fn vendor(&self) -> KeyVendor { + self.backend.vendor() + } + + /// The record a selection names: an id, a local name, or the default. + fn resolve(&self, keyset: Option<&IdentifiedBy>) -> Result<&KeysetRecord, Error> { + let id = match keyset { + None => self.default, + Some(IdentifiedBy::Uuid(id)) => Some(*id), + Some(IdentifiedBy::Name(name)) => self.names.get(&**name).copied(), + }; + id.and_then(|id| self.records.get(&id)) + .ok_or_else(|| not_found(self.vendor())) + } + + /// The record a data-key request names (`None` is the default keyset). + fn resolve_id(&self, keyset: Option) -> Result<&KeysetRecord, Error> { + self.resolve(keyset.map(IdentifiedBy::Uuid).as_ref()) + } + + /// The root key of `record`: from the cache while it is live, else + /// unwrapped (one vendor call) and cached for the TTL. + async fn root_key(&self, record: &KeysetRecord) -> Result { + let now = Instant::now(); + { + let mut roots = self.roots.lock().unwrap_or_else(PoisonError::into_inner); + // Every lookup drops every expired root key, so none outlives + // its TTL by more than the time to the next lookup. + roots.retain(|_, cached| now < cached.until); + if let Some(cached) = roots.get(&record.id()) { + return Ok(Protected::new(*cached.key.risky_ref())); + } + } + let key = self.unwrap_record(record).await?; + if !self.root_ttl.is_zero() { + if let Some(until) = Instant::now().checked_add(self.root_ttl) { + let mut roots = self.roots.lock().unwrap_or_else(PoisonError::into_inner); + let _ = roots.insert( + record.id(), + CachedRoot { + key: Protected::new(*key.risky_ref()), + until, + }, + ); + } + } + Ok(key) + } + + /// Unwrap the root key in `record` (one vendor call), with backoff on + /// a throttle. + async fn unwrap_record(&self, record: &KeysetRecord) -> Result { + retry( + &self.retry, + || record.unwrap_key(&self.backend), + |delay| self.backend.sleep(delay), + ) + .await + } + + /// The index key of `record`, derived from its root key. + async fn index_key(&self, record: &KeysetRecord) -> Result { + let root = self.root_key(record).await?; + Ok(IndexKey::from_key_bytes(derived_index_key( + &root, + record.id(), + record.vendor(), + record.mode(), + ))) + } +} + +impl DataKeySource for VendorKeySource { + async fn generate_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option>, + ) -> Result, Error> { + let vendor = self.vendor(); + refuse_zerokms_extensions(vendor, &payloads, unverified_context.as_deref())?; + let record = self.resolve_id(keyset_id)?; + let root = self.root_key(record).await?; + payloads + .iter() + .map(|payload| { + let iv = random_bytes::<16>(vendor)?; + let key = derived_data_key( + &root, + record.id(), + record.vendor(), + record.mode(), + &iv, + payload.descriptor(), + ); + Ok(DataKeyWithTag { + key: DataKey { + iv, + key: *key.risky_ref(), + }, + tag: Vec::new(), + decryption_policy: None, + }) + }) + .collect() + } + + async fn retrieve_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option<&UnverifiedContext>, + ) -> Result, Error> { + let vendor = self.vendor(); + refuse_zerokms_extensions(vendor, &payloads, unverified_context)?; + let record = self.resolve_id(keyset_id)?; + if payloads.iter().any(|payload| !payload.tag.is_empty()) { + return Err(invalid(vendor, "a derived data key has an empty tag")); + } + let root = self.root_key(record).await?; + Ok(payloads + .iter() + .map(|payload| { + let iv = payload.iv_bytes(); + let key = derived_data_key( + &root, + record.id(), + record.vendor(), + record.mode(), + &iv, + payload.descriptor, + ); + DataKey { + iv, + key: *key.risky_ref(), + } + }) + .collect()) + } +} + +impl IndexKeySource for VendorKeySource { + async fn load_index_key( + &self, + keyset_id: Option, + ) -> Result<(Uuid, IndexKey), Error> { + let record = self.resolve(keyset_id.as_ref())?; + Ok((record.id(), self.index_key(record).await?)) + } +} diff --git a/packages/stack-kms-vendor/src/static_key.rs b/packages/stack-kms-vendor/src/static_key.rs new file mode 100644 index 0000000000..fb1b62d498 --- /dev/null +++ b/packages/stack-kms-vendor/src/static_key.rs @@ -0,0 +1,216 @@ +//! The static key backend: a key held in memory. + +use std::collections::BTreeMap; +use std::fmt; + +use aes_gcm::aead::{Aead, KeyInit, Payload}; +use aes_gcm::{Aes256Gcm, Nonce}; +use stack_kms::{BackendErrorKind, Error, KeyVendor}; +use vitaminc::protected::{Controlled, Protected}; +use zeroize::Zeroizing; + +use crate::vendor::{random_bytes, KeyBytes, KeyVendorBackend}; + +/// The first byte of every key the static key backend wraps. +const WRAP_VERSION: u8 = 1; +/// The AES-GCM nonce length. +const NONCE_LEN: usize = 12; + +/// The static key backend ([`KeyVendor::Static`]): a 256-bit vendor key +/// held in the application's memory. +/// +/// **For development, or for customers who manage the key themselves.** +/// The vendor key is always in memory, so there is no vendor that can see +/// or refuse an unwrap. +/// +/// Each vendor key has a reference (`key_ref`), which the keyset record +/// stores. Hold more than one key ([`with_key`](Self::with_key)) to rotate: +/// rewrap each record under the new reference, then drop the old key. +/// +/// A key is wrapped with AES-256-GCM under a random 96-bit nonce, with the +/// associated data bound: `version (1) ‖ nonce (12) ‖ ciphertext ‖ tag`. +pub struct StaticKeyVendor { + keys: BTreeMap, +} + +impl fmt::Debug for StaticKeyVendor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StaticKeyVendor") + .field("key_refs", &self.keys.keys().collect::>()) + .finish() + } +} + +impl StaticKeyVendor { + /// A static key backend with one vendor key, named `key_ref`. + pub fn new(key_ref: impl Into, key: KeyBytes) -> Self { + Self { + keys: BTreeMap::new(), + } + .with_key(key_ref, key) + } + + /// Add another vendor key, named `key_ref`. A key of the same name is + /// replaced. + pub fn with_key(mut self, key_ref: impl Into, key: KeyBytes) -> Self { + let _ = self.keys.insert(key_ref.into(), key); + self + } + + fn cipher(&self, key_ref: &str) -> Result { + let key = self.keys.get(key_ref).ok_or_else(|| { + failure( + BackendErrorKind::NotFound, + "no static key has this reference", + ) + })?; + Aes256Gcm::new_from_slice(key.risky_ref()).map_err(|_| { + failure( + BackendErrorKind::NotFound, + "the static key has a bad length", + ) + }) + } +} + +fn failure(kind: BackendErrorKind, message: &'static str) -> Error { + Error::Backend { + backend: KeyVendor::Static.into(), + kind, + source: message.into(), + } +} + +impl KeyVendorBackend for StaticKeyVendor { + fn vendor(&self) -> KeyVendor { + KeyVendor::Static + } + + async fn wrap(&self, key_ref: &str, key: &KeyBytes, aad: &[u8]) -> Result, Error> { + let cipher = self.cipher(key_ref)?; + let nonce = random_bytes::(KeyVendor::Static)?; + let sealed = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: key.risky_ref(), + aad, + }, + ) + .map_err(|_| { + failure( + BackendErrorKind::Unavailable, + "the static key could not wrap", + ) + })?; + let mut out = Vec::with_capacity(1 + NONCE_LEN + sealed.len()); + out.push(WRAP_VERSION); + out.extend_from_slice(&nonce); + out.extend_from_slice(&sealed); + Ok(out) + } + + async fn unwrap(&self, key_ref: &str, wrapped: &[u8], aad: &[u8]) -> Result { + let invalid = || { + failure( + BackendErrorKind::InvalidCiphertext, + "the wrapped key does not open", + ) + }; + let cipher = self.cipher(key_ref)?; + let (version, rest) = wrapped.split_first().ok_or_else(invalid)?; + if *version != WRAP_VERSION || rest.len() < NONCE_LEN { + return Err(invalid()); + } + let (nonce, sealed) = rest.split_at(NONCE_LEN); + let opened = Zeroizing::new( + cipher + .decrypt(Nonce::from_slice(nonce), Payload { msg: sealed, aad }) + .map_err(|_| invalid())?, + ); + let key: [u8; 32] = opened.as_slice().try_into().map_err(|_| invalid())?; + Ok(Protected::new(key)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vendor() -> StaticKeyVendor { + StaticKeyVendor::new("dev", Protected::new([3u8; 32])) + } + + fn kind(error: Error) -> BackendErrorKind { + match error { + Error::Backend { backend, kind, .. } => { + assert_eq!(backend, KeyVendor::Static.into()); + kind + } + other => panic!("expected a backend error, got {other:?}"), + } + } + + #[tokio::test] + async fn a_key_round_trips_and_binds_its_aad() { + let vendor = vendor(); + let key = Protected::new([9u8; 32]); + let wrapped = vendor.wrap("dev", &key, b"aad").await.unwrap(); + assert_eq!(wrapped.len(), 1 + 12 + 32 + 16); + assert_eq!(wrapped[0], WRAP_VERSION); + let opened = vendor.unwrap("dev", &wrapped, b"aad").await.unwrap(); + assert_eq!(opened.risky_ref(), &[9u8; 32]); + // A fresh nonce each time. + assert_ne!(wrapped, vendor.wrap("dev", &key, b"aad").await.unwrap()); + + let refused = vendor.unwrap("dev", &wrapped, b"other").await.unwrap_err(); + assert_eq!(kind(refused), BackendErrorKind::InvalidCiphertext); + let mut damaged = wrapped.clone(); + damaged[20] ^= 1; + assert_eq!( + kind(vendor.unwrap("dev", &damaged, b"aad").await.unwrap_err()), + BackendErrorKind::InvalidCiphertext + ); + let mut relabelled = wrapped.clone(); + relabelled[0] = 2; + assert_eq!( + kind(vendor.unwrap("dev", &relabelled, b"aad").await.unwrap_err()), + BackendErrorKind::InvalidCiphertext + ); + assert_eq!( + kind(vendor.unwrap("dev", &[1, 2], b"aad").await.unwrap_err()), + BackendErrorKind::InvalidCiphertext + ); + assert_eq!( + kind(vendor.unwrap("dev", &[], b"aad").await.unwrap_err()), + BackendErrorKind::InvalidCiphertext + ); + } + + #[tokio::test] + async fn another_key_does_not_unwrap_and_an_unknown_ref_is_not_found() { + let vendor = vendor().with_key("next", Protected::new([4u8; 32])); + let wrapped = vendor + .wrap("dev", &Protected::new([9u8; 32]), b"aad") + .await + .unwrap(); + assert_eq!( + kind(vendor.unwrap("next", &wrapped, b"aad").await.unwrap_err()), + BackendErrorKind::InvalidCiphertext + ); + assert_eq!( + kind(vendor.unwrap("gone", &wrapped, b"aad").await.unwrap_err()), + BackendErrorKind::NotFound + ); + } + + #[test] + fn it_prints_no_key() { + let vendor = vendor(); + assert_eq!(vendor.vendor(), KeyVendor::Static); + assert_eq!( + format!("{vendor:?}"), + r#"StaticKeyVendor { key_refs: ["dev"] }"# + ); + } +} diff --git a/packages/stack-kms-vendor/src/transport.rs b/packages/stack-kms-vendor/src/transport.rs new file mode 100644 index 0000000000..8b98687ada --- /dev/null +++ b/packages/stack-kms-vendor/src/transport.rs @@ -0,0 +1,256 @@ +//! The HTTP seam a vendor client sends through. +//! +//! The official vendor SDKs need tokio and hyper and do not build for +//! wasm32-wasip1 (ADR-0014). Each vendor client is therefore a small REST +//! client that signs its own requests and sends them through +//! [`VendorTransport`]. Native Rust implements it over an HTTP client; the +//! WASI guest implements it over its `transport_send` host import. The +//! vendor code is the same on both. + +use std::future::Future; +use std::time::Duration; + +use stack_kms::{BackendErrorKind, MaybeSend}; +use zeroize::Zeroizing; + +/// One signed HTTP request to a vendor. +/// +/// The body can hold a key in the clear (an AWS `Encrypt` request) and the +/// headers can hold a credential (a session token, a bearer token), so the +/// body is wiped on drop and `Debug` shows only the method, the URL and +/// the header names. +#[derive(Clone, PartialEq, Eq)] +pub struct VendorRequest { + /// The HTTP method, upper case (`POST`). + pub method: String, + /// The full URL. + pub url: String, + /// The headers, signature included, in the order they are sent. + pub headers: Vec<(String, String)>, + /// The body. Wiped on drop. + pub body: Zeroizing>, +} + +impl VendorRequest { + /// A request with no headers and an empty body. + pub fn new(method: impl Into, url: impl Into) -> Self { + Self { + method: method.into(), + url: url.into(), + headers: Vec::new(), + body: Zeroizing::new(Vec::new()), + } + } + + /// Add a header. + pub fn header(mut self, name: impl Into, value: impl Into) -> Self { + self.headers.push((name.into(), value.into())); + self + } + + /// Set the body. + pub fn body(mut self, body: Vec) -> Self { + self.body = Zeroizing::new(body); + self + } + + /// The value of the first header named `name`, compared without case. + pub fn header_value(&self, name: &str) -> Option<&str> { + header_value(&self.headers, name) + } +} + +impl std::fmt::Debug for VendorRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VendorRequest") + .field("method", &self.method) + .field("url", &self.url) + .field( + "headers", + &self.headers.iter().map(|(n, _)| n).collect::>(), + ) + .field("body_len", &self.body.len()) + .finish() + } +} + +/// A vendor's HTTP response. +/// +/// The body can hold a key in the clear (an AWS `Decrypt` response) or a +/// credential (an STS response), so it is wiped on drop and `Debug` shows +/// only the status and the header names. +#[derive(Clone, PartialEq, Eq)] +pub struct VendorResponse { + /// The HTTP status. + pub status: u16, + /// The headers. + pub headers: Vec<(String, String)>, + /// The body. Wiped on drop. + pub body: Zeroizing>, +} + +impl VendorResponse { + /// A response with `status`, no headers and `body`. + pub fn new(status: u16, body: Vec) -> Self { + Self { + status, + headers: Vec::new(), + body: Zeroizing::new(body), + } + } + + /// Add a header. + pub fn header(mut self, name: impl Into, value: impl Into) -> Self { + self.headers.push((name.into(), value.into())); + self + } + + /// The value of the first header named `name`, compared without case. + pub fn header_value(&self, name: &str) -> Option<&str> { + header_value(&self.headers, name) + } +} + +impl std::fmt::Debug for VendorResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VendorResponse") + .field("status", &self.status) + .field( + "headers", + &self.headers.iter().map(|(n, _)| n).collect::>(), + ) + .field("body_len", &self.body.len()) + .finish() + } +} + +fn header_value<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> { + headers + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) +} + +/// The request did not get a response: the transport failed (DNS, TLS, +/// connection, timeout). +#[derive(Debug, thiserror::Error)] +#[error("the vendor request got no response: {0}")] +pub struct TransportError(pub String); + +/// The HTTP seam a vendor client sends through, of the same shape as +/// [`ZeroKMSConnection`](stack_kms::ZeroKMSConnection). +/// +/// The returned futures are bounded by [`MaybeSend`]: `Send` on native +/// targets, unbounded on wasm32. A transport gives the request no meaning: +/// it sends the bytes and returns the response. The vendor client signs +/// the request before, and classifies the response after +/// ([`classify_status`]). +pub trait VendorTransport { + /// Send `request` and return the response, whatever its status. + fn send( + &self, + request: VendorRequest, + ) -> impl Future> + MaybeSend; + + /// Wait for `duration`. A key vendor backend uses it to back off after + /// a rate limit ([`KeyVendorBackend::sleep`](crate::KeyVendorBackend::sleep)). + fn sleep(&self, duration: Duration) -> impl Future + MaybeSend; +} + +/// The failure kind of an HTTP status that is not a success, in terms that +/// hold for every vendor. `None` for a 2xx status. +/// +/// A vendor client may refine it from the response body (for example AWS +/// returns 400 for both a damaged ciphertext and a throttle). +pub fn classify_status(status: u16) -> Option { + match status { + 200..=299 => None, + 401 | 403 => Some(BackendErrorKind::AccessDenied), + 404 => Some(BackendErrorKind::NotFound), + 429 => Some(BackendErrorKind::Throttled), + 500..=599 => Some(BackendErrorKind::Unavailable), + _ => Some(BackendErrorKind::InvalidCiphertext), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + + /// A transport that answers from a script, as the vendor crates' tests + /// will over recorded fixtures. + struct Scripted { + responses: Mutex>, + sent: Mutex>, + } + + impl VendorTransport for Scripted { + async fn send(&self, request: VendorRequest) -> Result { + self.sent.lock().unwrap().push(request); + self.responses + .lock() + .unwrap() + .pop() + .ok_or_else(|| TransportError("no response".into())) + } + + async fn sleep(&self, _duration: Duration) {} + } + + #[tokio::test] + async fn a_transport_sends_bytes_and_returns_the_response() { + let transport = Scripted { + responses: Mutex::new(vec![VendorResponse::new(200, b"{}".to_vec())]), + sent: Mutex::new(vec![]), + }; + let request = VendorRequest::new("POST", "https://kms.example/") + .header("x-amz-target", "TrentService.Decrypt") + .body(b"{}".to_vec()); + assert_eq!( + request.header_value("X-Amz-Target"), + Some("TrentService.Decrypt") + ); + let response = transport.send(request.clone()).await.unwrap(); + assert_eq!(response.status, 200); + assert_eq!( + *transport.sent.lock().unwrap(), + std::slice::from_ref(&request) + ); + assert!(transport.send(request).await.is_err()); + } + + #[test] + fn debug_never_shows_a_body_or_a_header_value() { + let request = VendorRequest::new("POST", "https://kms.example/") + .header("x-amz-security-token", "secret-token") + .body(b"secret-key".to_vec()); + let text = format!("{request:?}"); + assert!(text.contains("x-amz-security-token")); + assert!(!text.contains("secret-token")); + assert!(!text.contains("secret-key")); + let response = VendorResponse::new(200, b"secret-plaintext".to_vec()) + .header("set-cookie", "secret-cookie"); + let text = format!("{response:?}"); + assert!(!text.contains("secret-plaintext")); + assert!(!text.contains("secret-cookie")); + assert_eq!(response.header_value("Set-Cookie"), Some("secret-cookie")); + } + + #[test] + fn statuses_classify_the_same_for_every_vendor() { + assert_eq!(classify_status(200), None); + assert_eq!(classify_status(204), None); + assert_eq!(classify_status(401), Some(BackendErrorKind::AccessDenied)); + assert_eq!(classify_status(403), Some(BackendErrorKind::AccessDenied)); + assert_eq!(classify_status(404), Some(BackendErrorKind::NotFound)); + assert_eq!(classify_status(429), Some(BackendErrorKind::Throttled)); + assert_eq!(classify_status(500), Some(BackendErrorKind::Unavailable)); + assert_eq!(classify_status(503), Some(BackendErrorKind::Unavailable)); + assert_eq!( + classify_status(400), + Some(BackendErrorKind::InvalidCiphertext) + ); + } +} diff --git a/packages/stack-kms-vendor/src/vendor.rs b/packages/stack-kms-vendor/src/vendor.rs new file mode 100644 index 0000000000..70a18388c8 --- /dev/null +++ b/packages/stack-kms-vendor/src/vendor.rs @@ -0,0 +1,105 @@ +//! The vendor seam: what a key vendor backend must provide. + +use std::future::Future; +use std::time::Duration; + +use stack_kms::{Error, KeyVendor, MaybeSend}; +use vitaminc::protected::Protected; +use vitaminc::random::{Generatable, SafeRand}; + +/// A 256-bit key, wiped when it drops: a root key, an index key or a data +/// key, in the clear. +pub type KeyBytes = Protected<[u8; 32]>; + +/// What a key vendor backend must provide: wrap and unwrap a key under a +/// vendor key, with associated data. +/// +/// This is the whole contract of a vendor. The adapter +/// ([`VendorKeySource`](crate::VendorKeySource)) builds the derived data key +/// mode, the keyset records and the root key TTL on top of it, so a vendor +/// crate (`stack-kms-aws` and so on) implements this trait and never +/// implements [`DataKeySource`](stack_kms::DataKeySource) or +/// [`IndexKeySource`](stack_kms::IndexKeySource) itself. +/// +/// # Rules for an implementation +/// +/// * **Bind the associated data.** `unwrap` must fail unless `aad` is the +/// `aad` given to `wrap`. A vendor with native associated data (AWS KMS +/// encryption context, Google Cloud KMS `additionalAuthenticatedData`) +/// passes it through. A vendor without it (Azure Key Vault), or with it +/// on some keys or versions only (Vault Transit), must bind it some other +/// way: wrap the bound key of +/// [`bound::bind_key`](crate::bound::bind_key) in place of the key, and +/// open it with [`bound::open_bound_key`](crate::bound::open_bound_key). +/// * **Report failures as [`Error::Backend`]**, with +/// this vendor ([`vendor`](Self::vendor)) as `backend` and the nearest +/// [`BackendErrorKind`](stack_kms::BackendErrorKind). A wrong key, a wrong +/// `aad` or damaged bytes is `InvalidCiphertext`. A rate limit is +/// `Throttled`: the adapter backs off and retries it. Never put key +/// material or plaintext in an error. +/// * **`key_ref` is the versioned vendor key reference** from the keyset +/// record (an AWS key ARN, a Vault key name, a Google Cloud KMS crypto +/// key name, an Azure key id). Its format is the vendor's. A vendor key +/// that rotates behind one name is versioned by the vendor: its +/// ciphertexts name the version that made them. +pub trait KeyVendorBackend: Send + Sync { + /// Which key vendor backend this is. + fn vendor(&self) -> KeyVendor; + + /// Wrap `key` under the vendor key `key_ref`, binding `aad`. + fn wrap( + &self, + key_ref: &str, + key: &KeyBytes, + aad: &[u8], + ) -> impl Future, Error>> + MaybeSend; + + /// Unwrap `wrapped` under the vendor key `key_ref`. Fails unless `aad` + /// is the `aad` the key was wrapped with. + fn unwrap( + &self, + key_ref: &str, + wrapped: &[u8], + aad: &[u8], + ) -> impl Future> + MaybeSend; + + /// Wait for `duration` before a retry. + /// + /// The adapter calls this to back off after a `Throttled` failure. The + /// default returns at once, because this crate has no timer of its own + /// and must run on native Rust and in the WASI guest alike. A vendor + /// overrides it with its transport's timer + /// ([`VendorTransport::sleep`](crate::VendorTransport::sleep)). + fn sleep(&self, duration: Duration) -> impl Future + MaybeSend { + let _ = duration; + std::future::ready(()) + } +} + +/// A fresh random 256-bit key, from the CSPRNG. +pub(crate) fn random_key(vendor: KeyVendor) -> Result { + let mut rng = SafeRand::from_entropy().map_err(|error| Error::Backend { + backend: vendor.into(), + kind: stack_kms::BackendErrorKind::Unavailable, + source: Box::new(error), + })?; + KeyBytes::random(&mut rng).map_err(|error| Error::Backend { + backend: vendor.into(), + kind: stack_kms::BackendErrorKind::Unavailable, + source: Box::new(error), + }) +} + +/// Fresh random bytes, from the CSPRNG. +pub(crate) fn random_bytes(vendor: KeyVendor) -> Result<[u8; N], Error> { + let mut rng = SafeRand::from_entropy().map_err(|error| Error::Backend { + backend: vendor.into(), + kind: stack_kms::BackendErrorKind::Unavailable, + source: Box::new(error), + })?; + <[u8; N]>::random(&mut rng).map_err(|error| Error::Backend { + backend: vendor.into(), + kind: stack_kms::BackendErrorKind::Unavailable, + source: Box::new(error), + }) +} diff --git a/packages/stack-kms-vendor/tasks.toml b/packages/stack-kms-vendor/tasks.toml new file mode 100644 index 0000000000..4101d6382d --- /dev/null +++ b/packages/stack-kms-vendor/tasks.toml @@ -0,0 +1,7 @@ +# Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc +# warning fails the build. The root `doc` task fans out over every +# `doc:`; the root `test:doc` runs the doc examples. +["doc:stack-kms-vendor"] +description = "Build docs for stack-kms-vendor with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-kms-vendor --no-deps --all-features" diff --git a/packages/stack-kms-vendor/tests/contract.rs b/packages/stack-kms-vendor/tests/contract.rs new file mode 100644 index 0000000000..b3da4e71f1 --- /dev/null +++ b/packages/stack-kms-vendor/tests/contract.rs @@ -0,0 +1,23 @@ +//! The shared contract suite, run against the static key backend and the +//! in-memory fake vendor. + +use stack_encrypt::kms::KeyVendor; +use stack_kms_vendor::contract; +use stack_kms_vendor::{FakeVendor, StaticKeyVendor}; +use vitaminc::protected::Protected; + +#[tokio::test] +async fn the_static_key_backend_meets_the_contract() { + contract::run( + || StaticKeyVendor::new("dev-key/v1", Protected::new([0x11u8; 32])), + "dev-key/v1", + ) + .await; +} + +#[tokio::test] +async fn the_fake_vendor_meets_the_contract() { + for vendor in [KeyVendor::Aws, KeyVendor::Vault] { + contract::run(|| FakeVendor::new(vendor), FakeVendor::KEY_REF).await; + } +} diff --git a/packages/stack-kms-vendor/tests/router.rs b/packages/stack-kms-vendor/tests/router.rs new file mode 100644 index 0000000000..688a2375af --- /dev/null +++ b/packages/stack-kms-vendor/tests/router.rs @@ -0,0 +1,346 @@ +//! ZeroKMS and one key vendor backend in one cipher, keyset records as +//! config, and the root key TTL in real time. + +use std::time::Duration; + +use stack_encrypt::kms::{ + BackendErrorKind, Error as KmsError, FakeDataKeySource, IdentifiedBy, KeyMode, KeyVendor, +}; +use stack_encrypt::{nonempty, CipherText, Error, SealedValue, StackCipher, StackCipherText}; +use stack_kms_vendor::contract::Observed; +use stack_kms_vendor::{ + FakeVendor, KeyRouter, KeysetRecord, RecordError, StaticKeyVendor, VendorKeySource, +}; +use uuid::Uuid; +use vitaminc::protected::Protected; + +const DERIVED: KeyMode = KeyMode::Derived; + +/// The one leaf a string seals as: the v1 leaf, which names its keyset +/// and no key backend. +fn leaf(sealed: &StackCipherText) -> &SealedValue { + match sealed { + CipherText::Single(leaf) => leaf, + _ => panic!("one leaf"), + } +} + +fn keyset_of(sealed: &StackCipherText) -> Uuid { + let leaf = leaf(sealed); + assert_eq!( + leaf.to_bytes().first(), + Some(&SealedValue::FORMAT_VERSION), + "every backend seals the v1 leaf" + ); + leaf.keyset_id() +} + +#[tokio::test] +async fn one_cipher_routes_between_zerokms_and_one_vendor() { + let vendor = FakeVendor::new(KeyVendor::Aws); + let named = KeysetRecord::create(&vendor, FakeVendor::KEY_REF, DERIVED, Some("acme".into())) + .await + .unwrap(); + let unnamed = KeysetRecord::create(&vendor, FakeVendor::KEY_REF, DERIVED, None) + .await + .unwrap(); + let texts = [named.to_string(), unnamed.to_string()]; + let source = VendorKeySource::from_texts(vendor, texts.iter().map(String::as_str)).unwrap(); + let cipher = StackCipher::builder() + .kms(KeyRouter::new(FakeDataKeySource::new(), source)) + .init() + .await + .unwrap(); + let context = nonempty!("users").with("email"); + + // Naming no keyset is ZeroKMS's default keyset. + let zerokms = cipher + .default_keyset() + .encrypt_tree("z".to_string(), context) + .await + .unwrap(); + assert_eq!(keyset_of(&zerokms), cipher.default_keyset().keyset_id()); + + // A record's name and id select the vendor. + let acme = cipher + .keyset(IdentifiedBy::Name("acme".to_string().into())) + .await + .unwrap(); + assert_eq!(acme.keyset_id(), named.id()); + let sealed_named = acme.encrypt_tree("n".to_string(), context).await.unwrap(); + assert_eq!(keyset_of(&sealed_named), named.id()); + let other = cipher.keyset(unnamed.id()).await.unwrap(); + let sealed_unnamed = other.encrypt_tree("u".to_string(), context).await.unwrap(); + assert_eq!(keyset_of(&sealed_unnamed), unnamed.id()); + assert!(leaf(&sealed_unnamed).tag().is_empty()); + + // Each leaf opens through the backend that holds its keyset. + for (sealed, expected) in [(zerokms, "z"), (sealed_named, "n"), (sealed_unnamed, "u")] { + let opened: String = cipher.decrypt(sealed, context).await.unwrap(); + assert_eq!(opened, expected); + } + + // A term through a vendor keyset differs from the ZeroKMS keyset's + // term for the same value. + let vendor_term = acme.equality_term("x", context).await.unwrap(); + let zerokms_term = cipher + .default_keyset() + .equality_term("x", context) + .await + .unwrap(); + assert_ne!(vendor_term.as_bytes(), zerokms_term.as_bytes()); +} + +/// A leaf of a keyset that no loaded record names goes to ZeroKMS, which +/// does not hold it: the leaf does not open, and the vendor gets no call. +#[tokio::test] +async fn a_leaf_of_an_unloaded_vendor_keyset_goes_to_zerokms_and_does_not_open() { + let vendor = FakeVendor::new(KeyVendor::Vault); + let record = KeysetRecord::create(&vendor, FakeVendor::KEY_REF, DERIVED, None) + .await + .unwrap(); + let sealer = StackCipher::builder() + .kms(VendorKeySource::new(vendor, [record]).unwrap()) + .init() + .await + .unwrap(); + let sealed = sealer + .default_keyset() + .encrypt_tree("v".to_string(), b"ctx") + .await + .unwrap(); + + let (backend, observer) = Observed::new(FakeVendor::new(KeyVendor::Vault)); + let other = KeysetRecord::create(&backend, FakeVendor::KEY_REF, DERIVED, None) + .await + .unwrap(); + let cipher = StackCipher::builder() + .kms(KeyRouter::new( + FakeDataKeySource::new(), + VendorKeySource::new(backend, [other]).unwrap(), + )) + .init() + .await + .unwrap(); + let before = observer.unwraps(); + assert!( + cipher.decrypt::(sealed, b"ctx").await.is_err(), + "the leaf's keyset is in no loaded record" + ); + assert_eq!(observer.unwraps(), before, "the vendor got no call"); +} + +#[tokio::test] +async fn records_of_two_vendors_are_refused() { + let aws = FakeVendor::new(KeyVendor::Aws); + let vault = FakeVendor::new(KeyVendor::Vault); + let a = KeysetRecord::create(&aws, FakeVendor::KEY_REF, DERIVED, None) + .await + .unwrap(); + let v = KeysetRecord::create(&vault, FakeVendor::KEY_REF, DERIVED, None) + .await + .unwrap(); + match VendorKeySource::new(aws, [a.clone(), v.clone()]) { + Err(RecordError::SecondVendor { first, second }) => { + assert_eq!((first, second), (KeyVendor::Aws, KeyVendor::Vault)); + } + other => panic!("expected SecondVendor, got {other:?}"), + } + match VendorKeySource::new(vault, [a]) { + Err(RecordError::ForeignVendor { + expected, found, .. + }) => assert_eq!((expected, found), (KeyVendor::Vault, KeyVendor::Aws)), + other => panic!("expected ForeignVendor, got {other:?}"), + } +} + +#[tokio::test] +async fn a_record_edited_in_its_bound_fields_does_not_unwrap() { + let vendor = FakeVendor::new(KeyVendor::Aws); + let record = KeysetRecord::create(&vendor, FakeVendor::KEY_REF, DERIVED, None) + .await + .unwrap(); + let moved = KeysetRecord::from_parts( + uuid::Uuid::new_v4(), + None, + record.vendor(), + record.mode(), + record.key_ref().into(), + record.wrapped_key().to_vec(), + ) + .unwrap(); + // Init loads the default keyset's index key, which unwraps the root key. + match StackCipher::builder() + .kms(VendorKeySource::new(vendor, [moved]).unwrap()) + .init() + .await + .map(|_| ()) + { + Err(Error::Kms(KmsError::Backend { + kind: BackendErrorKind::InvalidCiphertext, + .. + })) => {} + other => panic!("a record moved to another id must not unwrap, got {other:?}"), + } +} + +/// Seal one value and derive one term under `record`, rewrap the record to +/// `new_ref`, and check the old value opens and the term is the same. +async fn rewrap_keeps_old_values( + make: impl Fn() -> B, + record: KeysetRecord, + new_ref: &str, +) { + let old = StackCipher::builder() + .kms(VendorKeySource::new(make(), [record.clone()]).unwrap()) + .init() + .await + .unwrap(); + let sealed = old + .default_keyset() + .encrypt_tree("v".to_string(), b"ctx") + .await + .unwrap(); + let term = old + .default_keyset() + .equality_term("v", nonempty!("ctx")) + .await + .unwrap(); + + let rewrapped = record.rewrap(&make(), new_ref).await.unwrap(); + assert_eq!(rewrapped.id(), record.id()); + assert_eq!(rewrapped.mode(), record.mode()); + assert_eq!(rewrapped.key_ref(), new_ref); + let new = StackCipher::builder() + .kms(VendorKeySource::new(make(), [rewrapped]).unwrap()) + .init() + .await + .unwrap(); + let opened: String = new.decrypt(sealed, b"ctx").await.unwrap(); + assert_eq!(opened, "v", "a value sealed before the rewrap opens"); + let again = new + .default_keyset() + .equality_term("v", nonempty!("ctx")) + .await + .unwrap(); + assert_eq!( + term.as_bytes(), + again.as_bytes(), + "the index key did not change" + ); +} + +#[tokio::test] +async fn a_derived_record_rewraps_to_another_key_and_keeps_old_values() { + let vendor = || { + StaticKeyVendor::new("key-a", Protected::new([1u8; 32])) + .with_key("key-b", Protected::new([2u8; 32])) + }; + let record = KeysetRecord::create(&vendor(), "key-a", DERIVED, None) + .await + .unwrap(); + rewrap_keeps_old_values(vendor, record.clone(), "key-b").await; + + // The fake vendor too, which reports itself as a remote vendor. + let fake = || FakeVendor::new(KeyVendor::Aws).with_key("next/v2", Protected::new([2u8; 32])); + let record = KeysetRecord::create(&fake(), FakeVendor::KEY_REF, DERIVED, None) + .await + .unwrap(); + rewrap_keeps_old_values(fake, record, "next/v2").await; +} + +#[tokio::test] +async fn an_expired_root_key_is_unwrapped_again() { + let (backend, observer) = Observed::new(FakeVendor::new(KeyVendor::Gcp)); + let record = KeysetRecord::create(&backend, FakeVendor::KEY_REF, DERIVED, None) + .await + .unwrap(); + let cipher = StackCipher::builder() + .kms( + VendorKeySource::new(backend, [record]) + .unwrap() + .with_root_key_ttl(Duration::from_millis(100)), + ) + .init() + .await + .unwrap(); + let keyset = cipher.default_keyset(); + let _ = keyset.encrypt_tree("v".to_string(), b"ctx").await.unwrap(); + let _ = keyset.encrypt_tree("v".to_string(), b"ctx").await.unwrap(); + assert_eq!(observer.unwraps(), 1, "kept within its TTL"); + tokio::time::sleep(Duration::from_millis(150)).await; + let _ = keyset.encrypt_tree("v".to_string(), b"ctx").await.unwrap(); + assert_eq!(observer.unwraps(), 2, "unwrapped again after its TTL"); +} + +#[tokio::test] +async fn several_records_need_a_named_default() { + let vendor = FakeVendor::new(KeyVendor::Aws); + let a = KeysetRecord::create(&vendor, FakeVendor::KEY_REF, DERIVED, None) + .await + .unwrap(); + let b = KeysetRecord::create(&vendor, FakeVendor::KEY_REF, DERIVED, None) + .await + .unwrap(); + let source = VendorKeySource::new(vendor, [a.clone(), b]).unwrap(); + assert!( + StackCipher::builder().kms(source).init().await.is_err(), + "no default keyset among several records" + ); + + let vendor = FakeVendor::new(KeyVendor::Aws); + let source = VendorKeySource::new(vendor, [a.clone()]) + .unwrap() + .with_default_keyset(a.id()) + .unwrap(); + let cipher = StackCipher::builder().kms(source).init().await.unwrap(); + assert_eq!(cipher.default_keyset().keyset_id(), a.id()); + assert!(matches!( + VendorKeySource::new(FakeVendor::new(KeyVendor::Aws), [a]) + .unwrap() + .with_default_keyset(uuid::Uuid::nil()), + Err(RecordError::UnknownDefault) + )); +} + +/// A client with a key vendor backend and no ZeroKMS: naming no keyset is +/// the vendor's default keyset, and anything else goes to the ZeroKMS side. +#[tokio::test] +async fn a_vendor_default_router_selects_the_vendor_for_no_keyset() { + let vendor = FakeVendor::new(KeyVendor::Aws); + let record = KeysetRecord::create(&vendor, FakeVendor::KEY_REF, DERIVED, None) + .await + .unwrap(); + let source = VendorKeySource::new(vendor, [record.clone()]).unwrap(); + let router = KeyRouter::new(FakeDataKeySource::new(), source).with_vendor_default(); + let cipher = StackCipher::builder().kms(router).init().await.unwrap(); + assert_eq!(cipher.default_keyset().keyset_id(), record.id()); + let context = nonempty!("users").with("email"); + let sealed = cipher + .default_keyset() + .encrypt_tree("v".to_string(), context) + .await + .unwrap(); + assert_eq!(keyset_of(&sealed), record.id()); + let opened: String = cipher.decrypt(sealed, context).await.unwrap(); + assert_eq!(opened, "v"); +} + +/// A router with no vendor sends every request to ZeroKMS, the default +/// keyset included, even when it is told to prefer the vendor. +#[tokio::test] +async fn a_router_with_no_vendor_sends_everything_to_zerokms() { + let router = + KeyRouter::<_, FakeVendor>::zerokms_only(FakeDataKeySource::new()).with_vendor_default(); + assert!(router.vendor().is_none()); + let cipher = StackCipher::builder().kms(router).init().await.unwrap(); + let context = nonempty!("users").with("email"); + let sealed = cipher + .default_keyset() + .encrypt_tree("z".to_string(), context) + .await + .unwrap(); + assert_eq!(keyset_of(&sealed), cipher.default_keyset().keyset_id()); + let opened: String = cipher.decrypt(sealed, context).await.unwrap(); + assert_eq!(opened, "z"); +} diff --git a/release-plz.toml b/release-plz.toml index 382457a48c..bde4a1f591 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -19,6 +19,16 @@ # feature), so a crates.io release of `eql-bindings` needs the matching # `stack-encrypt` published first. # +# One more crate is publishable but STAGED: `stack-kms-vendor`, the `stack-kms-vendor` version group (the key vendor seam +# and every vendor crate that implements it: a change to the seam moves every +# vendor). crates.io Trusted Publishing can only be configured on a crate that +# exists, so each crate's first version is published by hand, after the +# stack-kms and stack-encrypt it depends on are on crates.io. Until then they +# carry `release = false` and `publish = false` here; the PR that arms them +# deletes those two lines from each and adds each to release-plz.yml's +# preflight loop. Their Cargo.toml files are already publishable, so +# tests-crates.yml's `cargo publish --dry-run` packages them on every PR. +# # Every other workspace member is `publish = false` in its Cargo.toml and is # also listed below with `release = false`, so `release-plz update` does not # bump its version or write its changelog either. Each also repeats @@ -76,3 +86,10 @@ publish = false name = "stack-auth-wasm" release = false publish = false + +# Staged: see the header. +[[package]] +name = "stack-kms-vendor" +version_group = "stack-kms-vendor" +release = false +publish = false diff --git a/scripts/__tests__/cargo-publish-opt-out.test.mjs b/scripts/__tests__/cargo-publish-opt-out.test.mjs index 6e1e465454..a571a5868c 100644 --- a/scripts/__tests__/cargo-publish-opt-out.test.mjs +++ b/scripts/__tests__/cargo-publish-opt-out.test.mjs @@ -57,7 +57,10 @@ const WORKSPACES = [ // exactly these five: stack-auth and stack-profile, and stack-kms, // stack-encrypt and stack-encrypt-derive (which `eql-bindings`' // `stack-encrypt` feature depends on from crates.io). stack-guest-abi is - // the Go guests' internal ABI and stays unpublished. + // the Go guests' internal ABI and stays unpublished. stack-kms-vendor + // is publishable but staged: release-plz.toml keeps it + // from releasing until its first version is published by hand (see + // release-plz-root-config.test.mjs). root: '.', publishable: new Set([ 'packages/stack-auth', @@ -65,6 +68,7 @@ const WORKSPACES = [ 'packages/stack-kms', 'packages/stack-encrypt', 'packages/stack-encrypt-derive', + 'packages/stack-kms-vendor', ]), expects: 'packages/stack-auth', }, diff --git a/scripts/__tests__/crates-ci.test.mjs b/scripts/__tests__/crates-ci.test.mjs index 72c5046127..feb8495d3c 100644 --- a/scripts/__tests__/crates-ci.test.mjs +++ b/scripts/__tests__/crates-ci.test.mjs @@ -16,7 +16,7 @@ import { readWorkflow, WORKFLOW_DIR, workflowFiles } from './lib/workflows.mjs' * EQL import, and `integrationSuiteCi.test.ts` for protect-ffi's before it. * This file is the same guard for this set, in two halves: * - * 1. Every mise task the set defines — the five crate `tasks.toml` files the + * 1. Every mise task the set defines — the six crate `tasks.toml` files the * root `mise.toml` includes, and the root tasks — is reached from a root * workflow, or named as exempt with a reason. The scan is a smaller copy * of `eql-suite-ci.test.mjs`'s: the same `invokes` rule for "a command, @@ -193,15 +193,37 @@ function expandMatrix(run, job) { const jobSteps = (job) => (Array.isArray(job?.steps) ? job.steps : []) +/** + * The `run:` bodies of a job. A job that calls a reusable workflow in this + * repository (`uses: ./.github/workflows/_x.yml`) runs that workflow's steps, + * with each `${{ inputs. }}` set to the caller's `with.`. Those + * bodies count for the CALLER: `_test-kms-live.yml` runs + * `mise run test:live:${{ inputs.crate }}`, which names no task until a + * caller says which crate, and deleting a caller must orphan its task. + */ +function jobBodies(job) { + const uses = String(job?.uses ?? '') + if (uses.startsWith(`./${WORKFLOW_DIR}/`)) { + const callee = readWorkflow(uses.slice(2)) + const inputs = job?.with ?? {} + return Object.values(callee?.jobs ?? {}).flatMap((calleeJob) => + jobBodies(calleeJob).map((body) => + body.replace(/\$\{\{\s*inputs\.([\w-]+)\s*\}\}/g, (whole, key) => + key in inputs ? String(inputs[key]) : whole, + ), + ), + ) + } + return jobSteps(job) + .filter((step) => typeof step?.run === 'string') + .flatMap((step) => expandMatrix(step.run, job)) +} + /** Every root workflow, with the `run:` bodies of all its jobs' steps. */ const ROOT_WORKFLOWS = workflowFiles().map((relPath) => { const wf = readWorkflow(relPath) const body = Object.values(wf?.jobs ?? {}) - .flatMap((job) => - jobSteps(job) - .filter((step) => typeof step?.run === 'string') - .flatMap((step) => expandMatrix(step.run, job)), - ) + .flatMap(jobBodies) .join('\n') return { relPath, wf, body } }) @@ -259,13 +281,14 @@ const CI_EXEMPT_TASKS = new Map([ ]) describe('the scan sees every task mise sees', () => { - it('reads the five crate task files from the root mise.toml', () => { + it('reads the six crate task files from the root mise.toml', () => { expect(INCLUDES.sort()).toEqual( [ 'packages/stack-auth/tasks.toml', 'packages/stack-encrypt/tasks.toml', 'packages/stack-guest-abi/tasks.toml', 'packages/stack-kms/tasks.toml', + 'packages/stack-kms-vendor/tasks.toml', 'packages/stack-profile/tasks.toml', ].sort(), ) @@ -315,6 +338,7 @@ describe('the scan sees every task mise sees', () => { 'doc:stack-encrypt', 'doc:stack-guest-abi', 'doc:stack-kms', + 'doc:stack-kms-vendor', 'doc:stack-profile', ].sort(), ) diff --git a/scripts/__tests__/release-plz-root-config.test.mjs b/scripts/__tests__/release-plz-root-config.test.mjs index f0fc6997c1..1eb4951716 100644 --- a/scripts/__tests__/release-plz-root-config.test.mjs +++ b/scripts/__tests__/release-plz-root-config.test.mjs @@ -38,6 +38,15 @@ const GROUPS = { 'stack-encrypt-derive': 'stack-encrypt', } const PUBLISHED = Object.keys(GROUPS).sort() +// Publishable crates whose first version is not on crates.io yet. crates.io +// Trusted Publishing needs an existing crate, so the first version is +// published by hand; until then release-plz.toml names their version group +// but keeps `release = false` and `publish = false`, so `release-crates` does +// not fail trying. Arming one moves it from here to GROUPS. +const STAGED = { + 'stack-kms-vendor': 'stack-kms-vendor', +} +const PUBLISHABLE = [...PUBLISHED, ...Object.keys(STAGED)].sort() /** The root workspace's members, as `{ path, name, publish }`. */ function rootMembers() { @@ -93,7 +102,7 @@ describe('root release-plz.toml', () => { .filter((member) => member.publish) .map((member) => member.name) .sort() - expect(publishable).toEqual(PUBLISHED) + expect(publishable).toEqual(PUBLISHABLE) const released = [...packages] .filter(([, config]) => config.release) @@ -106,8 +115,20 @@ describe('root release-plz.toml', () => { } }) + it('stages each crate not yet on crates.io in its version group', () => { + for (const [name, group] of Object.entries(STAGED)) { + expect(packages.get(name), name).toEqual({ + versionGroup: group, + release: false, + publish: false, + }) + } + }) + it('opts every unpublished member out of publishing here too', () => { - const unpublished = members.filter((member) => !member.publish) + const unpublished = members.filter( + (member) => !member.publish && !(member.name in STAGED), + ) expect(unpublished.length).toBeGreaterThan(0) for (const { name } of unpublished) { expect(packages.get(name)?.publish, name).toBe(false) @@ -123,7 +144,7 @@ describe('root release-plz.toml', () => { // behind from building and releasing. const versions = new Map() for (const { name, version } of members) { - const group = GROUPS[name] + const group = GROUPS[name] ?? STAGED[name] if (!group) continue expect(version, name).toBeTruthy() versions.set(group, [ diff --git a/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs b/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs index 3b02b86e77..1e6af29c53 100644 --- a/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs +++ b/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs @@ -218,7 +218,9 @@ const PERMISSIVE_NEEDS = { * switched on by `CLAUDE_REVIEW_ENABLED`; left unset it would read as skipped * on every event, and the fork guard would be credited with the skip. */ -const PERMISSIVE_VARS = { CLAUDE_REVIEW_ENABLED: 'true' } +const PERMISSIVE_VARS = { + CLAUDE_REVIEW_ENABLED: 'true', +} /** * `github.event` carries the webhook payload of the triggering event, so on a