diff --git a/.github/workflows/tests-crates.yml b/.github/workflows/tests-crates.yml index 2a89946d8..cb5ad8ce5 100644 --- a/.github/workflows/tests-crates.yml +++ b/.github/workflows/tests-crates.yml @@ -22,6 +22,7 @@ on: - packages/stack-kms/** - packages/stack-kms-vendor/** - packages/stack-kms-aws/** + - packages/stack-kms-vault/** - packages/stack-encrypt/** - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** @@ -48,6 +49,7 @@ on: - packages/stack-kms/** - packages/stack-kms-vendor/** - packages/stack-kms-aws/** + - packages/stack-kms-vault/** - packages/stack-encrypt/** - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** @@ -155,7 +157,7 @@ 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. stack-kms-vendor and stack-kms-aws + # resolves the published versions, which lag the tree. stack-kms-vendor, stack-kms-aws and stack-kms-vault # are publishable but staged in release-plz.toml (their # first version is published by hand); packaging them here keeps them # publishable meanwhile. No token. @@ -163,7 +165,7 @@ jobs: # 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 -p stack-kms-vendor -p stack-kms-aws + 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 -p stack-kms-aws -p stack-kms-vault # stack-kms-aws against a real AWS KMS API: LocalStack, as a service # (ADR-0014). The `rust` job runs the same test binary, where it skips @@ -209,6 +211,39 @@ jobs: - name: stack-kms-aws against LocalStack run: mise run test:localstack:stack-kms-aws + # stack-kms-vault against a real Vault Transit engine: `vault server -dev`, + # as a service (ADR-0014). The `rust` job runs the same test binary, where it + # skips itself for want of an address; this job sets the address, and the + # mise task refuses to run without one, so a skip cannot read as a pass here. + vault: + name: stack-kms-vault against vault server -dev + runs-on: blacksmith-16vcpu-ubuntu-2204 + timeout-minutes: 30 + env: + MISE_ENV: test + STACK_KMS_VAULT_ADDR: http://127.0.0.1:8200 + STACK_KMS_VAULT_TOKEN: root + services: + vault: + # Pinned by digest (the multi-platform index of 1.20, 1.20.4 when + # pinned). The image's default command is `server -dev`. + image: hashicorp/vault:1.20@sha256:268bb80aa9c6d13d65fcfa05c0c268caca068952240a8087291a6ce0b66e3a10 + ports: + - 8200:8200 + env: + VAULT_DEV_ROOT_TOKEN_ID: root + VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200 + SKIP_SETCAP: 'true' + options: >- + --health-cmd "wget -qO- http://127.0.0.1:8200/v1/sys/health" + --health-interval 5s + --health-timeout 5s + --health-retries 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 with: version: 2026.4.0 @@ -220,6 +255,8 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - name: stack-kms-vault against vault server -dev + run: mise run test:vault:stack-kms-vault node-bindings: name: node bindings, napi typings, stack-auth-wasm diff --git a/.github/workflows/udeps.yml b/.github/workflows/udeps.yml index a89e9d8b3..3204e78bc 100644 --- a/.github/workflows/udeps.yml +++ b/.github/workflows/udeps.yml @@ -16,6 +16,7 @@ on: - packages/stack-kms/** - packages/stack-kms-vendor/** - packages/stack-kms-aws/** + - packages/stack-kms-vault/** - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** @@ -37,6 +38,7 @@ on: - packages/stack-kms/** - packages/stack-kms-vendor/** - packages/stack-kms-aws/** + - packages/stack-kms-vault/** - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** diff --git a/AGENTS.md b/AGENTS.md index 826874348..66a92e0ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +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`, `packages/stack-kms-aws`: The key vendor backends (ADR-0013 and ADR-0014 in `packages/stack-encrypt/docs/adr/`). `stack-kms-vendor` is the vendor seam (`KeyVendorBackend`), keyset records, the derived data key mode, the static key backend and the `VendorTransport` HTTP seam (feature `http`: the native reqwest transport). Its `test-support` feature holds the test code every vendor crate shares: the contract suite (`contract`), `FakeVendor`, and the recorded-fixture harness (`replay`: fixture types, `Replay`, `Recording`); a vendor crate's `tests/common/mod.rs` holds only its own emulator, keys and constants. `stack-kms-aws` is the AWS KMS backend: a hand-written JSON client with SigV4 signing in-crate and the AWS credential providers; its LocalStack test runs in `tests-crates.yml`'s `localstack` job. All two are root workspace members, publishable in the `stack-kms-vendor` version group, and staged in `release-plz.toml` (`release = false`) until their first version is published by hand. The `stack-kms-vendor` crate docs hold the capability matrix of every key backend (key lifetime, vendor calls, what the vendor sees and can refuse, credentials, rotation, known limits); change it with the code it describes. TypeScript (`@cipherstash/stack`) cannot use a key vendor backend yet. +- `packages/stack-kms-vendor`, `packages/stack-kms-aws`, `packages/stack-kms-vault`: The key vendor backends (ADR-0013 and ADR-0014 in `packages/stack-encrypt/docs/adr/`). `stack-kms-vendor` is the vendor seam (`KeyVendorBackend`), keyset records, the derived data key mode, the static key backend and the `VendorTransport` HTTP seam (feature `http`: the native reqwest transport). Its `test-support` feature holds the test code every vendor crate shares: the contract suite (`contract`), `FakeVendor`, and the recorded-fixture harness (`replay`: fixture types, `Replay`, `Recording`); a vendor crate's `tests/common/mod.rs` holds only its own emulator, keys and constants. `stack-kms-aws` is the AWS KMS backend: a hand-written JSON client with SigV4 signing in-crate and the AWS credential providers; its LocalStack test runs in `tests-crates.yml`'s `localstack` job. `stack-kms-vault` is the HashiCorp Vault Transit backend: a hand-written Transit client with token, AppRole and Kubernetes auth; its `vault server -dev` test runs in `tests-crates.yml`'s `vault` job. All three are root workspace members, publishable in the `stack-kms-vendor` version group, and staged in `release-plz.toml` (`release = false`) until their first version is published by hand. The `stack-kms-vendor` crate docs hold the capability matrix of every key backend (key lifetime, vendor calls, what the vendor sees and can refuse, credentials, rotation, known limits); change it with the code it describes. TypeScript (`@cipherstash/stack`) cannot use a key vendor backend yet. - `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. - `languages/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) @@ -716,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 eight stack-* +- **Three Cargo workspaces, not one.** The root workspace (the nine 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 @@ -758,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 seven crate `tasks.toml` files loses its last CI caller. + `mise.toml` or the eight 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 24a43b94d..7c16eb6eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3541,6 +3541,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "stack-kms-vault" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "base64ct", + "serde", + "serde_json", + "sha2 0.11.0", + "stack-encrypt", + "stack-kms", + "stack-kms-vendor", + "thiserror 1.0.69", + "tokio", + "url", + "uuid", + "vitaminc 0.9.0", + "zeroize", +] + [[package]] name = "stack-kms-vendor" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 098f24afa..9035ee64f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "packages/stack-kms", "packages/stack-kms-vendor", "packages/stack-kms-aws", + "packages/stack-kms-vault", "packages/stack-encrypt", "packages/stack-encrypt-derive", "packages/stack-guest-abi", diff --git a/SECURITY.md b/SECURITY.md index a35774b15..50b1ac515 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,8 +38,8 @@ 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. -Two more Rust crates, **`stack-kms-vendor`** and **`stack-kms-aws`** -(`packages/stack-kms-vendor`, `packages/stack-kms-aws`), are prepared for +Three more Rust crates, **`stack-kms-vendor`**, **`stack-kms-aws`** and **`stack-kms-vault`** +(`packages/stack-kms-vendor`, `packages/stack-kms-aws`, `packages/stack-kms-vault`), are prepared for crates.io but not yet published. They are in scope on the same terms, and under the same licence as the stack-* crates below. diff --git a/mise.toml b/mise.toml index 869583c33..0addab9a6 100644 --- a/mise.toml +++ b/mise.toml @@ -12,6 +12,7 @@ includes = [ "packages/stack-kms/tasks.toml", "packages/stack-kms-vendor/tasks.toml", "packages/stack-kms-aws/tasks.toml", + "packages/stack-kms-vault/tasks.toml", "packages/stack-profile/tasks.toml", "packages/stack-encrypt/tasks.toml", "packages/stack-guest-abi/tasks.toml", @@ -129,6 +130,9 @@ check stack-kms-vendor --features http # The AWS KMS key vendor backend, default features on: its native transport # and workload identity credential sources are gated off on wasm32. check stack-kms-aws +# The Vault Transit key vendor backend, default features on: its native +# transport and Kubernetes auth are gated off on wasm32. +check stack-kms-vault # 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-vault/Cargo.toml b/packages/stack-kms-vault/Cargo.toml new file mode 100644 index 000000000..2ffc99680 --- /dev/null +++ b/packages/stack-kms-vault/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "stack-kms-vault" +description = "The HashiCorp Vault Transit key vendor backend for Stack Encrypt: a hand-written Transit client, for native Rust and the WASI guest" +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 the vendor seam it +# implements. release-plz.toml keeps `release = false` until the first version +# is on crates.io (published by hand; see stack-kms-vendor's Cargo.toml). + +[features] +default = ["http"] +# `VaultTransit::from_env`: the native transport (`HttpVendorTransport`) and +# the default auth method over it. Native only; on wasm32 it adds nothing. +http = ["stack-kms-vendor/http"] + +[dependencies] +stack-kms = { path = "../stack-kms", version = "0.2.0", default-features = false } +stack-kms-vendor = { path = "../stack-kms-vendor", version = "0.1.0" } + +vitaminc = { workspace = true, features = ["protected"] } +base64ct = { version = "1.7", features = ["alloc"] } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } +zeroize = { workspace = true } + +[dev-dependencies] +stack-kms-vendor = { path = "../stack-kms-vendor", features = ["test-support", "http"] } +stack-encrypt = { path = "../stack-encrypt", default-features = false } +aes-gcm = { version = "0.10.3", features = ["zeroize"] } +sha2 = "0.11" +tokio = { workspace = true, features = ["rt", "macros", "time"] } +uuid = { workspace = true } +vitaminc = { workspace = true, features = ["protected", "random"] } + +[package.metadata.docs.rs] +all-features = true diff --git a/packages/stack-kms-vault/LICENSE b/packages/stack-kms-vault/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-kms-vault/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-vault/src/auth.rs b/packages/stack-kms-vault/src/auth.rs new file mode 100644 index 000000000..246d0633c --- /dev/null +++ b/packages/stack-kms-vault/src/auth.rs @@ -0,0 +1,538 @@ +//! Vault tokens and the auth methods that get them. +//! +//! [`TokenProvider`] is the seam: the client asks it for a [`VaultToken`] +//! before each request. This crate provides: +//! +//! * [`VaultToken`] itself: a static token, from an explicit value or from +//! `VAULT_TOKEN` ([`VaultToken::from_env`]). Everywhere. The operator +//! renews it. +//! * [`AppRoleLogin`]: AppRole auth (`role_id` and `secret_id` to +//! `/v1/auth/{mount}/login`). Everywhere. +//! * [`KubernetesLogin`]: Kubernetes auth (the pod's service account token +//! to `/v1/auth/{mount}/login`). **Native only**: it reads the token file. +//! * [`CachedToken`]: keeps the token a login returns and logs in again +//! before its lease ends. +//! * [`DefaultAuth`] (native): picks one from the environment. +//! +//! In the WASI guest the host supplies the token (ADR-0014): the guest +//! implements [`TokenProvider`] over a host import. +//! +//! A login always makes a new token. The client does not call +//! `renew-self`: a renewal cannot pass the token's max TTL, so a client +//! that renews must also log in again, and one path is simpler to test. +//! Note that each AppRole login uses up one use of a `secret_id` with a +//! use limit (`secret_id_num_uses`). + +use std::fmt; +use std::future::Future; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use stack_kms::{BackendErrorKind, MaybeSend}; +use stack_kms_vendor::{ + classify_status, CredentialError, CredentialKind, Expiring, Expiry, FetchCredential, + VendorRequest, VendorTransport, +}; +use zeroize::Zeroizing; + +use crate::config::{is_path, process_env, EnvLookup, VaultConfig}; + +/// A Vault token, and how long its lease lasts from when it was issued. +/// +/// The token is wiped on drop and never printed. +#[derive(Clone)] +pub struct VaultToken { + token: Zeroizing, + lease: Option, +} + +impl fmt::Debug for VaultToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("VaultToken") + .field("token", &"") + .field("lease", &self.lease) + .finish() + } +} + +impl VaultToken { + /// A token with no known lease: it is used until Vault refuses it. + pub fn new(token: impl Into) -> Self { + Self { + token: Zeroizing::new(token.into()), + lease: None, + } + } + + /// The same token, whose lease lasts `lease` from now. + pub fn with_lease(mut self, lease: Duration) -> Self { + self.lease = Some(lease); + self + } + + /// The token from `VAULT_TOKEN`, or `None` when it is not set. + pub fn from_env() -> Option { + Self::from_env_lookup(&process_env()) + } + + /// As [`from_env`](Self::from_env), reading the variable with `env`. + pub fn from_env_lookup(env: &EnvLookup) -> Option { + env("VAULT_TOKEN").map(Self::new) + } + + /// The token. + pub fn token(&self) -> &str { + &self.token + } + + /// How long the lease lasts from when the token was issued. `None` for + /// a token with no known lease. + pub fn lease(&self) -> Option { + self.lease + } +} + +impl Expiring for VaultToken { + fn expiry(&self) -> Expiry { + self.lease.map_or(Expiry::Never, Expiry::After) + } +} + +pub use stack_kms_vendor::DEFAULT_REFRESH_BEFORE_EXPIRY; + +/// The credential kind of this crate: a [`VaultToken`], one for the +/// client. It names [`AuthError`] and [`CachedToken`]. +#[derive(Debug)] +pub enum VaultAuth {} + +impl CredentialKind for VaultAuth { + const LABEL: &'static str = "Vault auth"; + type Key = (); + type Credential = VaultToken; +} + +/// A token could not be got. The message starts with "Vault auth". +pub type AuthError = CredentialError; + +/// Where a client gets its Vault token. The client asks before each +/// request, so a provider that logs in should cache ([`CachedToken`]). +/// +/// The returned future is bounded by [`MaybeSend`]: `Send` on native +/// targets, unbounded on wasm32. +pub trait TokenProvider: Send + Sync { + /// The current token. + fn token(&self) -> impl Future> + MaybeSend; + + /// Vault refused the current token (HTTP 403). A caching provider drops + /// it, so that the next request logs in again. The default does + /// nothing. + fn invalidate(&self) {} +} + +impl TokenProvider for VaultToken { + fn token(&self) -> impl Future> + MaybeSend { + std::future::ready(Ok(self.clone())) + } +} + +/// Keeps the token `inner` returns, and gets a new one when its lease is +/// near its end: [`DEFAULT_REFRESH_BEFORE_EXPIRY`] before, or a third of +/// the lease before when the lease is shorter than three times that. +/// +/// One login runs at a time: callers that arrive during a login wait for +/// it and share its result. If a login fails while the cached token's lease +/// has not ended, the cached token is still returned. A token with no +/// lease is kept until Vault refuses it ([`TokenProvider::invalidate`]). +pub type CachedToken

= stack_kms_vendor::CachedToken; + +impl FetchCredential

for VaultAuth { + fn fetch<'a>( + provider: &'a P, + _key: &'a (), + ) -> impl Future> + MaybeSend + 'a { + provider.token() + } + + fn invalidate(provider: &P) { + provider.invalidate(); + } +} + +impl TokenProvider for CachedToken

{ + fn token(&self) -> impl Future> + MaybeSend { + self.get(&()) + } + + fn invalidate(&self) { + stack_kms_vendor::CachedToken::invalidate(self); + } +} + +#[derive(Deserialize)] +struct LoginResponse { + auth: Option, +} + +#[derive(Deserialize)] +struct LoginAuth { + client_token: Option, + #[serde(default)] + lease_duration: u64, +} + +#[derive(Deserialize)] +struct ErrorBody { + #[serde(default)] + errors: Vec, +} + +/// Log in at `/v1/auth/{mount}/login` with the JSON `body`. The body is +/// wiped after it is sent. +async fn login( + config: &VaultConfig, + transport: &T, + method: &'static str, + mount: &str, + body: Zeroizing>, +) -> Result { + let mut request = VendorRequest::new("POST", config.url(&format!("auth/{mount}/login"))) + .header("content-type", "application/json") + .header("x-vault-request", "true"); + request.body = body; + if let Some(namespace) = config.namespace() { + request = request.header("x-vault-namespace", namespace); + } + let response = transport.send(request).await.map_err(|error| { + AuthError::unavailable(format!("{method} login got no response: {error}")) + })?; + if let Some(kind) = classify_status(response.status) { + // A refused login is HTTP 400 ("invalid role or secret ID") or 403. + let kind = match kind { + BackendErrorKind::InvalidCiphertext => BackendErrorKind::AccessDenied, + kind => kind, + }; + let errors = serde_json::from_slice::(&response.body) + .map(|body| body.errors.join("; ")) + .unwrap_or_default(); + return Err(AuthError::new( + kind, + format!( + "{method} login failed with HTTP {}: {errors}", + response.status + ), + )); + } + let parsed: LoginResponse = serde_json::from_slice(&response.body).map_err(|_| { + AuthError::unavailable(format!( + "{method} login returned a response that is not JSON" + )) + })?; + let auth = parsed + .auth + .ok_or_else(|| AuthError::unavailable(format!("{method} login returned no auth")))?; + let token = Zeroizing::new( + auth.client_token + .ok_or_else(|| AuthError::unavailable(format!("{method} login returned no token")))?, + ); + let token = VaultToken { token, lease: None }; + Ok(match auth.lease_duration { + 0 => token, + seconds => token.with_lease(Duration::from_secs(seconds)), + }) +} + +fn check_mount(method: &str, mount: &str) -> Result<(), AuthError> { + if is_path(mount) { + Ok(()) + } else { + Err(AuthError::access_denied(format!( + "{mount:?} is not a {method} auth mount path" + ))) + } +} + +/// The default mount path of AppRole auth. +pub const DEFAULT_APPROLE_MOUNT: &str = "approle"; + +/// AppRole auth: `role_id` and `secret_id` to `/v1/auth/{mount}/login`. +/// Wrap it in a [`CachedToken`]. +pub struct AppRoleLogin { + config: VaultConfig, + transport: T, + mount: String, + role_id: String, + secret_id: Zeroizing, +} + +impl fmt::Debug for AppRoleLogin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AppRoleLogin") + .field("mount", &self.mount) + .field("role_id", &self.role_id) + .field("secret_id", &"") + .finish_non_exhaustive() + } +} + +impl AppRoleLogin { + /// Log in to the server of `config` (address and namespace) through + /// `transport`, at [`DEFAULT_APPROLE_MOUNT`]. + pub fn new( + config: &VaultConfig, + transport: T, + role_id: impl Into, + secret_id: impl Into, + ) -> Self { + Self { + config: config.clone(), + transport, + mount: DEFAULT_APPROLE_MOUNT.to_owned(), + role_id: role_id.into(), + secret_id: Zeroizing::new(secret_id.into()), + } + } + + /// Log in at the AppRole auth mount `mount` instead. + pub fn with_mount(mut self, mount: impl Into) -> Self { + self.mount = mount.into().trim_matches('/').to_owned(); + self + } + + /// The transport. + pub fn transport(&self) -> &T { + &self.transport + } +} + +#[derive(Serialize)] +struct AppRoleBody<'a> { + role_id: &'a str, + secret_id: &'a str, +} + +impl TokenProvider for AppRoleLogin { + async fn token(&self) -> Result { + check_mount("AppRole", &self.mount)?; + let body = Zeroizing::new( + serde_json::to_vec(&AppRoleBody { + role_id: &self.role_id, + secret_id: &self.secret_id, + }) + .map_err(|_| AuthError::unavailable("AppRole login body"))?, + ); + login(&self.config, &self.transport, "AppRole", &self.mount, body).await + } +} + +#[cfg(not(target_arch = "wasm32"))] +mod kubernetes { + use std::path::PathBuf; + + use super::*; + + /// The default mount path of Kubernetes auth. + pub const DEFAULT_KUBERNETES_MOUNT: &str = "kubernetes"; + + /// Where Kubernetes mounts the pod's service account token. + pub const DEFAULT_SERVICE_ACCOUNT_TOKEN_PATH: &str = + "/var/run/secrets/kubernetes.io/serviceaccount/token"; + + /// Kubernetes auth: the pod's service account token (a JWT) and a + /// Vault role to `/v1/auth/{mount}/login`. The token file is read on + /// each login, because Kubernetes rotates it. Native only. Wrap it in a + /// [`CachedToken`]. + pub struct KubernetesLogin { + config: VaultConfig, + transport: T, + mount: String, + role: String, + token_path: PathBuf, + } + + impl fmt::Debug for KubernetesLogin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("KubernetesLogin") + .field("mount", &self.mount) + .field("role", &self.role) + .field("token_path", &self.token_path) + .finish_non_exhaustive() + } + } + + impl KubernetesLogin { + /// Log in as `role` to the server of `config` through `transport`, + /// at [`DEFAULT_KUBERNETES_MOUNT`], with the token at + /// [`DEFAULT_SERVICE_ACCOUNT_TOKEN_PATH`]. + pub fn new(config: &VaultConfig, transport: T, role: impl Into) -> Self { + Self { + config: config.clone(), + transport, + mount: DEFAULT_KUBERNETES_MOUNT.to_owned(), + role: role.into(), + token_path: PathBuf::from(DEFAULT_SERVICE_ACCOUNT_TOKEN_PATH), + } + } + + /// Log in at the Kubernetes auth mount `mount` instead. + pub fn with_mount(mut self, mount: impl Into) -> Self { + self.mount = mount.into().trim_matches('/').to_owned(); + self + } + + /// Read the service account token from `path` instead. + pub fn with_token_path(mut self, path: impl Into) -> Self { + self.token_path = path.into(); + self + } + } + + #[derive(Serialize)] + struct KubernetesBody<'a> { + role: &'a str, + jwt: &'a str, + } + + impl TokenProvider for KubernetesLogin { + async fn token(&self) -> Result { + check_mount("Kubernetes", &self.mount)?; + let jwt = + Zeroizing::new(std::fs::read_to_string(&self.token_path).map_err(|error| { + AuthError::access_denied(format!( + "cannot read the service account token at {}: {}", + self.token_path.display(), + error.kind() + )) + })?); + let body = Zeroizing::new( + serde_json::to_vec(&KubernetesBody { + role: &self.role, + jwt: jwt.trim(), + }) + .map_err(|_| AuthError::unavailable("Kubernetes login body"))?, + ); + login( + &self.config, + &self.transport, + "Kubernetes", + &self.mount, + body, + ) + .await + } + } + + /// The auth method [`DefaultAuth::from_env`] picks. + #[derive(Debug)] + pub enum DefaultAuth { + /// `VAULT_TOKEN`. + Token(VaultToken), + /// `VAULT_APPROLE_ROLE_ID` and `VAULT_APPROLE_SECRET_ID`. + AppRole(CachedToken>), + /// `VAULT_KUBERNETES_ROLE`. + Kubernetes(CachedToken>), + } + + impl DefaultAuth { + /// Pick an auth method from the environment, in this order: + /// + /// 1. `VAULT_TOKEN`: a static token. + /// 2. `VAULT_APPROLE_ROLE_ID` and `VAULT_APPROLE_SECRET_ID`: AppRole, + /// at `VAULT_APPROLE_MOUNT` (default `approle`). + /// 3. `VAULT_KUBERNETES_ROLE`: Kubernetes, at + /// `VAULT_KUBERNETES_MOUNT` (default `kubernetes`), with the token + /// at `VAULT_KUBERNETES_TOKEN_PATH` (default the service account + /// token path). + /// + /// The login methods log in through `transport` and are cached. + pub fn from_env(config: &VaultConfig, transport: T) -> Result { + Self::from_env_lookup(config, transport, &process_env()) + } + + /// As [`from_env`](Self::from_env), reading the variables with `env`. + pub fn from_env_lookup( + config: &VaultConfig, + transport: T, + env: &EnvLookup, + ) -> Result { + if let Some(token) = VaultToken::from_env_lookup(env) { + return Ok(Self::Token(token)); + } + if let (Some(role_id), Some(secret_id)) = ( + env("VAULT_APPROLE_ROLE_ID"), + env("VAULT_APPROLE_SECRET_ID").map(Zeroizing::new), + ) { + let mut login = AppRoleLogin::new(config, transport, role_id, secret_id.as_str()); + if let Some(mount) = env("VAULT_APPROLE_MOUNT") { + login = login.with_mount(mount); + } + return Ok(Self::AppRole(CachedToken::new(login))); + } + if let Some(role) = env("VAULT_KUBERNETES_ROLE") { + let mut login = KubernetesLogin::new(config, transport, role); + if let Some(mount) = env("VAULT_KUBERNETES_MOUNT") { + login = login.with_mount(mount); + } + if let Some(path) = env("VAULT_KUBERNETES_TOKEN_PATH") { + login = login.with_token_path(path); + } + return Ok(Self::Kubernetes(CachedToken::new(login))); + } + Err(AuthError::access_denied( + "no Vault auth: set VAULT_TOKEN, VAULT_APPROLE_ROLE_ID and \ + VAULT_APPROLE_SECRET_ID, or VAULT_KUBERNETES_ROLE", + )) + } + } + + impl TokenProvider for DefaultAuth { + async fn token(&self) -> Result { + match self { + Self::Token(token) => Ok(token.clone()), + Self::AppRole(login) => login.token().await, + Self::Kubernetes(login) => login.token().await, + } + } + + fn invalidate(&self) { + match self { + Self::Token(_) => {} + Self::AppRole(login) => login.invalidate(), + Self::Kubernetes(login) => login.invalidate(), + } + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub use kubernetes::{ + DefaultAuth, KubernetesLogin, DEFAULT_KUBERNETES_MOUNT, DEFAULT_SERVICE_ACCOUNT_TOKEN_PATH, +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_hides_the_token() { + let text = format!("{:?}", VaultToken::new("hvs.secret")); + assert!(!text.contains("hvs.secret")); + let text = format!( + "{:?}", + AppRoleLogin::new( + &VaultConfig::new("http://vault:8200").unwrap(), + (), + "role", + "the-secret-id" + ) + ); + assert!(text.contains("role")); + assert!(!text.contains("the-secret-id")); + } + + #[test] + fn the_lease_is_the_expiry() { + assert_eq!(VaultToken::new("t").expiry(), Expiry::Never); + let token = VaultToken::new("t").with_lease(Duration::from_secs(60)); + assert_eq!(token.expiry(), Expiry::After(Duration::from_secs(60))); + } +} diff --git a/packages/stack-kms-vault/src/client.rs b/packages/stack-kms-vault/src/client.rs new file mode 100644 index 000000000..3cc595e64 --- /dev/null +++ b/packages/stack-kms-vault/src/client.rs @@ -0,0 +1,668 @@ +//! The Vault Transit client and its [`KeyVendorBackend`] implementation. + +use std::collections::HashMap; +use std::sync::{Mutex, PoisonError}; +use std::time::Duration; + +use base64ct::{Base64, Encoding}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use stack_kms::{BackendErrorKind, Error, KeyVendor}; +use stack_kms_vendor::bound::{bind_key, open_bound_key}; +use stack_kms_vendor::{ + classify_status, KeyBytes, KeyVendorBackend, TransportError, VendorRequest, VendorResponse, + VendorTransport, +}; +use zeroize::Zeroizing; + +use crate::auth::{AuthError, TokenProvider}; +use crate::config::{is_segment, KeyDerivation, VaultConfig}; + +/// This client's vendor: what [`KeyVendorBackend::vendor`] answers and what its errors name. +const VENDOR: KeyVendor = KeyVendor::Vault; + +/// The first byte of a wrapped key this client stores: the format of the +/// rest (`version(4, BE) ‖ Vault ciphertext bytes`). +pub const WRAPPED_KEY_FORMAT: u8 = 1; + +/// The Transit key types whose encryption is an AEAD. The client binds the +/// associated data inside the plaintext +/// ([`stack_kms_vendor::bound`]), which is sound only under a +/// non-malleable cipher, so [`KeyDerivation::Detect`] refuses other types. +pub const AEAD_KEY_TYPES: [&str; 3] = ["aes256-gcm96", "aes128-gcm96", "chacha20-poly1305"]; + +/// The text in a Vault HTTP 400 error that means "this ciphertext does not +/// open" ([`BackendErrorKind::InvalidCiphertext`]), lower case. Checked +/// after [`NOT_FOUND_MARKERS`]. +pub const INVALID_CIPHERTEXT_MARKERS: [&str; 6] = [ + "invalid ciphertext", + "message authentication failed", + "unable to decrypt", + "could not decode", + "ciphertext version", + "version is too new", +]; + +/// The text in a Vault HTTP 400 error that means "no such key" +/// ([`BackendErrorKind::NotFound`]), lower case. +pub const NOT_FOUND_MARKERS: [&str; 2] = ["encryption key not found", "key not found"]; + +/// A failure of a Vault call. It is the `source` of the [`Error::Backend`] +/// the client returns; the kind is on the `Error::Backend`. +#[derive(Debug, thiserror::Error)] +pub enum VaultError { + /// Vault answered with an error. + #[error("Vault {operation} failed with HTTP {status}: {message}")] + Service { + /// The operation (`decrypt`). + operation: &'static str, + /// The HTTP status. + status: u16, + /// Vault's error text. + message: String, + }, + /// The request got no response. + #[error("Vault {operation} got no response: {source}")] + Transport { + /// The operation. + operation: &'static str, + /// The transport failure. + source: TransportError, + }, + /// No token could be got. + #[error(transparent)] + Auth(#[from] AuthError), + /// Vault answered with a success that this client cannot read. + #[error("Vault {operation} returned a response this client cannot read: {reason}")] + InvalidResponse { + /// The operation. + operation: &'static str, + /// What is wrong with it. + reason: &'static str, + }, + /// The client refused the call before or after it reached Vault. + #[error("{0}")] + Refused(&'static str), +} + +fn backend(kind: BackendErrorKind, error: VaultError) -> Error { + Error::Backend { + backend: VENDOR.into(), + kind, + source: Box::new(error), + } +} + +fn invalid(operation: &'static str, reason: &'static str) -> Error { + backend( + BackendErrorKind::Unavailable, + VaultError::InvalidResponse { operation, reason }, + ) +} + +fn refused(kind: BackendErrorKind, reason: &'static str) -> Error { + backend(kind, VaultError::Refused(reason)) +} + +/// The failure kind of a Vault error response. +/// +/// * 401 and 403: `AccessDenied`. 404: `NotFound` (a mount or key that does +/// not exist). 429: `Throttled` (a rate limit quota). 5xx: `Unavailable` +/// (503 is a sealed or standby Vault). +/// * 400: by the error text, lower case. [`NOT_FOUND_MARKERS`] first +/// (`NotFound`), then [`INVALID_CIPHERTEXT_MARKERS`] +/// (`InvalidCiphertext`). Any other 400 is a request Vault refused +/// (`AccessDenied`), for example a derived key with no `context`. +/// * Any other 4xx: `AccessDenied`. +pub fn error_kind(status: u16, message: &str) -> BackendErrorKind { + if status == 400 { + let message = message.to_ascii_lowercase(); + return if NOT_FOUND_MARKERS.iter().any(|m| message.contains(m)) { + BackendErrorKind::NotFound + } else if INVALID_CIPHERTEXT_MARKERS + .iter() + .any(|m| message.contains(m)) + { + BackendErrorKind::InvalidCiphertext + } else { + BackendErrorKind::AccessDenied + }; + } + match classify_status(status) { + // Another 4xx is a refused request, not a damaged ciphertext. + Some(BackendErrorKind::InvalidCiphertext) => BackendErrorKind::AccessDenied, + Some(kind) => kind, + None => BackendErrorKind::Unavailable, + } +} + +#[derive(Deserialize)] +struct ErrorBody { + #[serde(default)] + errors: Vec, + data: Option, +} + +/// Vault's error text: the error of the first failed `batch_input` item, +/// else the `errors` list. +fn failure_message(body: &[u8]) -> String { + let Ok(body) = serde_json::from_slice::(body) else { + return String::new(); + }; + body.data + .and_then(|data| { + data.batch_results + .into_iter() + .find_map(|result| result.error.filter(|e| !e.is_empty())) + }) + .unwrap_or_else(|| body.errors.join("; ")) +} + +fn service_error(operation: &'static str, status: u16, message: String) -> Error { + backend( + error_kind(status, &message), + VaultError::Service { + operation, + status, + message, + }, + ) +} + +#[derive(Deserialize)] +struct BatchResponse { + data: Option, +} + +#[derive(Deserialize)] +struct BatchData { + #[serde(default)] + batch_results: Vec, +} + +#[derive(Deserialize)] +struct BatchResult { + ciphertext: Option, + plaintext: Option, + error: Option, +} + +/// A request body with one `batch_input` item. The client sends one item +/// in each call, but uses the batch form so that every Transit answer has +/// one shape. +#[derive(Serialize)] +struct BatchRequest<'a, I> { + batch_input: [&'a I; 1], +} + +#[derive(Serialize)] +struct EncryptItem { + #[serde(serialize_with = "serialize_secret")] + plaintext: Zeroizing, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option, +} + +fn serialize_secret( + value: &Zeroizing, + serializer: S, +) -> Result { + serializer.serialize_str(value) +} + +#[derive(Serialize)] +struct CiphertextItem { + ciphertext: String, + #[serde(skip_serializing_if = "Option::is_none")] + context: Option, +} + +#[derive(Deserialize)] +struct KeyRead { + data: Option, +} + +#[derive(Deserialize)] +struct KeyData { + #[serde(default)] + derived: bool, + #[serde(rename = "type")] + key_type: Option, + #[serde(default)] + supports_encryption: bool, + #[serde(default)] + supports_decryption: bool, +} + +/// The stored form of a Vault ciphertext (`vault:v:`): +/// [`WRAPPED_KEY_FORMAT`], the key version (4 bytes, big-endian), and the +/// decoded bytes. About 40 bytes shorter than the text. +pub fn compact_ciphertext(ciphertext: &str) -> Option> { + let rest = ciphertext.strip_prefix("vault:v")?; + let (version, encoded) = rest.split_once(':')?; + if version.is_empty() || !version.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let version: u32 = version.parse().ok()?; + let bytes = Base64::decode_vec(encoded).ok()?; + if bytes.is_empty() || Base64::encode_string(&bytes) != encoded { + return None; + } + let mut out = Vec::with_capacity(5 + bytes.len()); + out.push(WRAPPED_KEY_FORMAT); + out.extend_from_slice(&version.to_be_bytes()); + out.extend_from_slice(&bytes); + Some(out) +} + +/// The Vault ciphertext text of a stored wrapped key. `None` if the bytes +/// are not one. +pub fn expand_ciphertext(wrapped: &[u8]) -> Option { + let (&format, rest) = wrapped.split_first()?; + if format != WRAPPED_KEY_FORMAT { + return None; + } + let (version, bytes) = rest.split_at_checked(4)?; + if bytes.is_empty() { + return None; + } + let version = u32::from_be_bytes(version.try_into().ok()?); + Some(format!("vault:v{version}:{}", Base64::encode_string(bytes))) +} + +/// A HashiCorp Vault Transit client, and the Vault Transit key vendor +/// backend ([`KeyVendor::Vault`]). +/// +/// It speaks the Transit secrets engine API (`encrypt`, `decrypt` and +/// `rewrap`) over a [`VendorTransport`], so the same code runs on native +/// Rust and in the WASI guest. Each call sends one key, as a `batch_input` +/// of one item. +/// +/// * `key_ref` (from the keyset record) is the Transit key name. Vault +/// ciphertexts name the key version (`vault:v3:`), so rotation needs no +/// change to the record. [`rewrap`](Self::rewrap) moves a wrapped key to +/// the latest version. +/// * **Calls.** The client calls Vault for keyset records only. One +/// `encrypt` call creates or rewraps a record. One `decrypt` call unwraps +/// the root key of a keyset, once for each root key TTL. No call is made +/// for each value. +/// * **Associated data.** The client always encrypts a bound key +/// ([`stack_kms_vendor::bound`]): the digest of the associated data and +/// the key. For a derived key (`derived=true`) it also sends the +/// associated data as `context`. Vault requires it, and it binds the +/// keyset id in Vault's own key derivation. The client learns which keys +/// are derived from [`KeyDerivation`]. +/// * Errors map to [`BackendErrorKind`] by [`error_kind`]. A 403 also tells +/// the token provider to drop its token ([`TokenProvider::invalidate`]). +pub struct VaultTransit { + config: VaultConfig, + transport: T, + auth: A, + /// Whether each key is derived, for [`KeyDerivation::Detect`]. + derived: Mutex>, +} + +impl std::fmt::Debug for VaultTransit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VaultTransit") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl VaultTransit { + /// A client for `config`, sending through `transport`, with the tokens + /// of `auth`. + pub fn new(config: VaultConfig, transport: T, auth: A) -> Self { + Self { + config, + transport, + auth, + derived: Mutex::new(HashMap::new()), + } + } + + /// The configuration. + pub fn config(&self) -> &VaultConfig { + &self.config + } + + /// The transport. + pub fn transport(&self) -> &T { + &self.transport + } + + /// The token provider. + pub fn auth(&self) -> &A { + &self.auth + } + + /// Send one request to the API path `path` (`transit/keys/my-key`, no + /// `v1/`), with a token, and return the response of a success. + async fn request( + &self, + operation: &'static str, + method: &str, + path: &str, + body: Option>>, + ) -> Result { + let token = self.auth.token().await.map_err(|error| { + let kind = error.kind(); + backend(kind, VaultError::Auth(error)) + })?; + let mut request = VendorRequest::new(method, self.config.url(path)) + .header("x-vault-token", token.token()) + .header("x-vault-request", "true"); + if let Some(namespace) = self.config.namespace() { + request = request.header("x-vault-namespace", namespace); + } + if let Some(body) = body { + request = request.header("content-type", "application/json"); + request.body = body; + } + let response = self.transport.send(request).await.map_err(|source| { + backend( + BackendErrorKind::Unavailable, + VaultError::Transport { operation, source }, + ) + })?; + if classify_status(response.status).is_some() { + if response.status == 403 { + self.auth.invalidate(); + } + return Err(service_error( + operation, + response.status, + failure_message(&response.body), + )); + } + Ok(response) + } + + /// Send one request to the API path `path` (no leading `/`, no `v1/`) + /// and read the JSON of a success. `method` is the HTTP method. Any + /// Vault API can be called this way (for example to enable the Transit + /// engine in a test); [`KeyVendorBackend`] uses `encrypt`, `decrypt` and + /// `keys`. The body is wiped after it is sent. + pub async fn send_json( + &self, + method: &str, + path: &str, + body: Option>>, + ) -> Result { + let response = self.request("request", method, path, body).await?; + serde_json::from_slice(&response.body).map_err(|_| invalid("request", "invalid JSON")) + } + + /// The API path of `operation` on the key `key_ref`. + fn key_path(&self, operation: &str, key_ref: &str) -> Result { + if !is_segment(key_ref) { + return Err(refused( + BackendErrorKind::NotFound, + "the key reference is not a Vault Transit key name", + )); + } + Ok(format!("{}/{operation}/{key_ref}", self.config.mount())) + } + + /// Whether the key `key_ref` is derived: from the config, or read from + /// Vault once per key ([`KeyDerivation::Detect`]). + pub async fn is_derived(&self, key_ref: &str) -> Result { + match self.config.key_derivation() { + KeyDerivation::Derived => return Ok(true), + KeyDerivation::NotDerived => return Ok(false), + KeyDerivation::Detect => {} + } + let path = self.key_path("keys", key_ref)?; + if let Some(derived) = self + .derived + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(key_ref) + { + return Ok(*derived); + } + let response = self.request("read key", "GET", &path, None).await?; + let read: KeyRead = serde_json::from_slice(&response.body) + .map_err(|_| invalid("read key", "invalid JSON"))?; + let data = read.data.ok_or_else(|| invalid("read key", "no data"))?; + let aead = data + .key_type + .as_deref() + .is_some_and(|key_type| AEAD_KEY_TYPES.contains(&key_type)); + if !aead || !data.supports_encryption || !data.supports_decryption { + return Err(refused( + BackendErrorKind::AccessDenied, + "the Transit key is not an AEAD encryption key \ + (aes256-gcm96, aes128-gcm96 or chacha20-poly1305)", + )); + } + let _ = self + .derived + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(key_ref.to_owned(), data.derived); + Ok(data.derived) + } + + /// Send `item` to `operation` on `key_ref`, as a `batch_input` of one + /// item, and return its result. + async fn call( + &self, + operation: &'static str, + key_ref: &str, + item: &I, + ) -> Result { + let path = self.key_path(operation, key_ref)?; + let body = Zeroizing::new( + serde_json::to_vec(&BatchRequest { + batch_input: [item], + }) + .map_err(|_| invalid(operation, "unserializable request"))?, + ); + let response = self.request(operation, "POST", &path, Some(body)).await?; + let parsed: BatchResponse = serde_json::from_slice(&response.body) + .map_err(|_| invalid(operation, "invalid JSON"))?; + let mut results = parsed + .data + .ok_or_else(|| invalid(operation, "no data"))? + .batch_results; + if let Some(error) = results + .iter() + .find_map(|result| result.error.as_deref().filter(|e| !e.is_empty())) + { + return Err(service_error(operation, 400, error.to_owned())); + } + match (results.pop(), results.is_empty()) { + (Some(result), true) => Ok(result), + _ => Err(invalid(operation, "not exactly one batch result")), + } + } + + /// The `context` of `aad` for a derived key. + fn context(derived: bool, aad: &[u8]) -> Option { + derived.then(|| Base64::encode_string(aad)) + } + + /// Move a wrapped key to the latest version of its Transit key + /// (`rewrap`), without the key leaving Vault. `aad` is the associated + /// data it was wrapped with. Old versions can then be retired with the + /// key's `min_decryption_version`. + pub async fn rewrap( + &self, + key_ref: &str, + wrapped: &[u8], + aad: &[u8], + ) -> Result, Error> { + let derived = self.is_derived(key_ref).await?; + let item = CiphertextItem { + ciphertext: expand(wrapped)?, + context: Self::context(derived, aad), + }; + let result = self.call("rewrap", key_ref, &item).await?; + stored("rewrap", result) + } +} + +/// The Vault ciphertext of a stored wrapped key. +fn expand(wrapped: &[u8]) -> Result { + expand_ciphertext(wrapped).ok_or_else(|| { + refused( + BackendErrorKind::InvalidCiphertext, + "the wrapped key is not a Vault Transit wrapped key", + ) + }) +} + +/// The stored form of the ciphertext of a result. +fn stored(operation: &'static str, result: BatchResult) -> Result, Error> { + let ciphertext = result + .ciphertext + .ok_or_else(|| invalid(operation, "no ciphertext"))?; + compact_ciphertext(&ciphertext) + .ok_or_else(|| invalid(operation, "the ciphertext is not a Vault ciphertext")) +} + +impl KeyVendorBackend for VaultTransit { + fn vendor(&self) -> KeyVendor { + VENDOR + } + + async fn wrap(&self, key_ref: &str, key: &KeyBytes, aad: &[u8]) -> Result, Error> { + let derived = self.is_derived(key_ref).await?; + let bound = bind_key(key, aad); + let item = EncryptItem { + plaintext: Zeroizing::new(Base64::encode_string(&bound)), + context: Self::context(derived, aad), + }; + let result = self.call("encrypt", key_ref, &item).await?; + stored("encrypt", result) + } + + async fn unwrap(&self, key_ref: &str, wrapped: &[u8], aad: &[u8]) -> Result { + let derived = self.is_derived(key_ref).await?; + let item = CiphertextItem { + ciphertext: expand(wrapped)?, + context: Self::context(derived, aad), + }; + let result = self.call("decrypt", key_ref, &item).await?; + let encoded = Zeroizing::new( + result + .plaintext + .ok_or_else(|| invalid("decrypt", "no plaintext"))?, + ); + let bound = Zeroizing::new( + Base64::decode_vec(&encoded) + .map_err(|_| invalid("decrypt", "the plaintext is not base64"))?, + ); + open_bound_key(&bound, aad).map_err(|_| { + refused( + BackendErrorKind::InvalidCiphertext, + "the wrapped key does not belong to this associated data", + ) + }) + } + + async fn sleep(&self, duration: Duration) { + self.transport.sleep(duration).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn errors_map_by_status_and_text() { + for (status, message, kind) in [ + (400, "encryption key not found", BackendErrorKind::NotFound), + ( + 400, + "invalid ciphertext: no prefix", + BackendErrorKind::InvalidCiphertext, + ), + ( + 400, + "cipher: message authentication failed", + BackendErrorKind::InvalidCiphertext, + ), + ( + 400, + "invalid ciphertext: could not decode base64", + BackendErrorKind::InvalidCiphertext, + ), + ( + 400, + "invalid ciphertext: version is too new", + BackendErrorKind::InvalidCiphertext, + ), + ( + 400, + "ciphertext version is disallowed by policy (too old)", + BackendErrorKind::InvalidCiphertext, + ), + ( + 400, + "missing 'context' for key derivation", + BackendErrorKind::AccessDenied, + ), + (403, "permission denied", BackendErrorKind::AccessDenied), + (401, "", BackendErrorKind::AccessDenied), + (404, "", BackendErrorKind::NotFound), + ( + 429, + "rate limit quota exceeded", + BackendErrorKind::Throttled, + ), + (500, "", BackendErrorKind::Unavailable), + (503, "Vault is sealed", BackendErrorKind::Unavailable), + (412, "", BackendErrorKind::AccessDenied), + ] { + assert_eq!(error_kind(status, message), kind, "{status} {message}"); + } + } + + #[test] + fn the_message_comes_from_a_failed_item_or_the_errors() { + assert_eq!( + failure_message(br#"{"errors":["a","b"]}"#), + "a; b".to_owned() + ); + assert_eq!( + failure_message( + br#"{"data":{"batch_results":[{"plaintext":"x"},{"error":"cipher: message authentication failed"}]}}"# + ), + "cipher: message authentication failed" + ); + assert_eq!(failure_message(b"not json"), ""); + } + + #[test] + fn ciphertexts_compact_and_expand_losslessly() { + let text = "vault:v12:uN5CE4+rarIuFgLc3Etlpo1eG8jFreK4KUzYvlTMZQ=="; + let compact = compact_ciphertext(text).unwrap(); + assert_eq!(compact[0], WRAPPED_KEY_FORMAT); + assert_eq!(&compact[1..5], &12u32.to_be_bytes()); + assert_eq!(expand_ciphertext(&compact).unwrap(), text); + for bad in [ + "vault:v1:", + "vault:1:AAAA", + "vault:v:AAAA", + "vault:v-1:AAAA", + "vault:v1:!!!!", + "vault:v1:AAA", + "vault:v99999999999:AAAA", + "AAAA", + ] { + assert_eq!(compact_ciphertext(bad), None, "{bad}"); + } + for bad in [ + &[][..], + &[2, 0, 0, 0, 1, 9], + &[1, 0, 0, 1], + &[1, 0, 0, 0, 1], + ] { + assert_eq!(expand_ciphertext(bad), None, "{bad:?}"); + } + } +} diff --git a/packages/stack-kms-vault/src/config.rs b/packages/stack-kms-vault/src/config.rs new file mode 100644 index 000000000..859d691d0 --- /dev/null +++ b/packages/stack-kms-vault/src/config.rs @@ -0,0 +1,277 @@ +//! The address, namespace and mount of a Vault Transit client. + +/// How an environment variable is read: the process environment unless a +/// test gives another. +pub type EnvLookup = std::sync::Arc Option + Send + Sync>; + +/// The process environment. An empty variable counts as unset. +pub fn process_env() -> EnvLookup { + std::sync::Arc::new(|name| std::env::var(name).ok().filter(|value| !value.is_empty())) +} + +/// The default mount path of the Transit secrets engine. +pub const DEFAULT_MOUNT: &str = "transit"; + +/// The configuration is not usable. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ConfigError { + /// No address was given, and `VAULT_ADDR` is not set. + #[error("no Vault address: set VAULT_ADDR or give one in the config")] + NoAddress, + /// The address is not an absolute HTTP or HTTPS URL with no query. + #[error("{0:?} is not an HTTP or HTTPS Vault address")] + InvalidAddress(String), + /// The mount path is not a Vault path. + #[error("{0:?} is not a Vault mount path")] + InvalidMount(String), + /// The namespace is not a Vault namespace path. + #[error("{0:?} is not a Vault namespace")] + InvalidNamespace(String), +} + +/// Whether a Transit key derives a key per request from `context` +/// (`derived=true`). +/// +/// Vault requires `context` for a derived key and silently ignores it for +/// a key that is not derived, so the client must know which. Either way the +/// client also binds the associated data itself +/// ([`stack_kms_vendor::bound`]), so a wrong answer can make a call fail but +/// never makes a wrapped key open under other associated data. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum KeyDerivation { + /// Read the key's configuration (`GET /v1/{mount}/keys/{name}`) on the + /// first use of each key, and keep the answer. The token needs `read` on + /// that path. This also refuses a key that does not exist (Transit's + /// `encrypt` would otherwise create it) and a key type that is not an + /// AEAD. The default. + #[default] + Detect, + /// Every key is derived: send the associated data as `context`. No key + /// read. + Derived, + /// No key is derived: send no `context`. No key read. + NotDerived, +} + +/// A path of one or more segments of `[A-Za-z0-9._-]`, none `.` or `..`. +pub(crate) fn is_path(text: &str) -> bool { + !text.is_empty() && text.split('/').all(is_segment) +} + +/// One path segment of `[A-Za-z0-9._-]`, not `.` or `..`. +pub(crate) fn is_segment(text: &str) -> bool { + !text.is_empty() + && text != "." + && text != ".." + && text + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +/// The address, namespace, mount path and key derivation of a +/// [`VaultTransit`](crate::VaultTransit) client. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VaultConfig { + address: String, + namespace: Option, + mount: String, + derivation: KeyDerivation, +} + +impl VaultConfig { + /// The Vault server at `address` (`https://vault.example.com:8200`), + /// with the Transit engine at [`DEFAULT_MOUNT`] and no namespace. + pub fn new(address: impl Into) -> Result { + let address = address.into(); + let parsed = + url::Url::parse(&address).map_err(|_| ConfigError::InvalidAddress(address.clone()))?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host().is_none() + || parsed.query().is_some() + || parsed.fragment().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return Err(ConfigError::InvalidAddress(address)); + } + let address = parsed.as_str().trim_end_matches('/').to_owned(); + Ok(Self { + address, + namespace: None, + mount: DEFAULT_MOUNT.to_owned(), + derivation: KeyDerivation::Detect, + }) + } + + /// The address from `VAULT_ADDR`, the namespace from `VAULT_NAMESPACE`, + /// and the mount path from `VAULT_TRANSIT_MOUNT` (default + /// [`DEFAULT_MOUNT`]). + pub fn from_env() -> Result { + Self::from_env_lookup(&process_env()) + } + + /// As [`from_env`](Self::from_env), reading the variables with `env`. + pub fn from_env_lookup(env: &EnvLookup) -> Result { + let mut config = Self::new(env("VAULT_ADDR").ok_or(ConfigError::NoAddress)?)?; + if let Some(namespace) = env("VAULT_NAMESPACE") { + config = config.with_namespace(namespace)?; + } + if let Some(mount) = env("VAULT_TRANSIT_MOUNT") { + config = config.with_mount(mount)?; + } + Ok(config) + } + + /// Send every request in `namespace` (Vault Enterprise and HCP Vault), + /// as the `X-Vault-Namespace` header. A leading or trailing `/` is + /// removed. + pub fn with_namespace(mut self, namespace: impl Into) -> Result { + let namespace = namespace.into(); + let trimmed = namespace.trim_matches('/'); + if !is_path(trimmed) { + return Err(ConfigError::InvalidNamespace(namespace)); + } + self.namespace = Some(trimmed.to_owned()); + Ok(self) + } + + /// Use the Transit engine mounted at `mount` (`transit`, + /// `encryption/transit`). A leading or trailing `/` is removed. + pub fn with_mount(mut self, mount: impl Into) -> Result { + let mount = mount.into(); + let trimmed = mount.trim_matches('/'); + if !is_path(trimmed) { + return Err(ConfigError::InvalidMount(mount)); + } + self.mount = trimmed.to_owned(); + Ok(self) + } + + /// How the client learns whether a key is derived. Default + /// [`KeyDerivation::Detect`]. + pub fn with_key_derivation(mut self, derivation: KeyDerivation) -> Self { + self.derivation = derivation; + self + } + + /// The address, with no trailing `/`. + pub fn address(&self) -> &str { + &self.address + } + + /// The namespace, if any. + pub fn namespace(&self) -> Option<&str> { + self.namespace.as_deref() + } + + /// The mount path of the Transit engine. + pub fn mount(&self) -> &str { + &self.mount + } + + /// How the client learns whether a key is derived. + pub fn key_derivation(&self) -> KeyDerivation { + self.derivation + } + + /// The URL of the API path `path` (no leading `/`, no `v1/`). + pub fn url(&self, path: &str) -> String { + format!("{}/v1/{path}", self.address) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use super::*; + + fn env(pairs: &[(&str, &str)]) -> EnvLookup { + let map: HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(); + Arc::new(move |name| map.get(name).cloned()) + } + + #[test] + fn the_address_is_normalised_and_checked() { + let config = VaultConfig::new("https://vault.example.com:8200/").unwrap(); + assert_eq!(config.address(), "https://vault.example.com:8200"); + assert_eq!( + config.url("transit/encrypt/k"), + "https://vault.example.com:8200/v1/transit/encrypt/k" + ); + assert_eq!(config.mount(), "transit"); + assert_eq!(config.namespace(), None); + assert_eq!(config.key_derivation(), KeyDerivation::Detect); + for bad in [ + "vault:8200", + "ftp://vault/", + "https://vault/?x=1", + "https://vault/#x", + "https://user:pass@vault/", + "not a url", + ] { + assert_eq!( + VaultConfig::new(bad), + Err(ConfigError::InvalidAddress(bad.to_owned())), + "{bad}" + ); + } + } + + #[test] + fn mounts_and_namespaces_are_paths() { + let config = VaultConfig::new("http://127.0.0.1:8200").unwrap(); + let config = config + .with_mount("/encryption/transit/") + .unwrap() + .with_namespace("admin/team-a/") + .unwrap(); + assert_eq!(config.mount(), "encryption/transit"); + assert_eq!(config.namespace(), Some("admin/team-a")); + for bad in ["", "a//b", "../sys", "a/./b", "a b", "a?b", "a\nb", "a%2fb"] { + assert_eq!( + config.clone().with_mount(bad), + Err(ConfigError::InvalidMount(bad.to_owned())), + "{bad:?}" + ); + assert_eq!( + config.clone().with_namespace(bad), + Err(ConfigError::InvalidNamespace(bad.to_owned())), + "{bad:?}" + ); + } + } + + #[test] + fn the_config_comes_from_the_environment() { + assert_eq!( + VaultConfig::from_env_lookup(&env(&[])), + Err(ConfigError::NoAddress) + ); + let config = VaultConfig::from_env_lookup(&env(&[ + ("VAULT_ADDR", "https://vault:8200"), + ("VAULT_NAMESPACE", "admin"), + ("VAULT_TRANSIT_MOUNT", "kms"), + ])) + .unwrap(); + assert_eq!(config.address(), "https://vault:8200"); + assert_eq!(config.namespace(), Some("admin")); + assert_eq!(config.mount(), "kms"); + assert_eq!( + VaultConfig::from_env_lookup(&env(&[("VAULT_ADDR", "vault")])), + Err(ConfigError::InvalidAddress("vault".to_owned())) + ); + } + + #[test] + fn the_derivation_is_set() { + let config = VaultConfig::new("http://127.0.0.1:8200") + .unwrap() + .with_key_derivation(KeyDerivation::Derived); + assert_eq!(config.key_derivation(), KeyDerivation::Derived); + } +} diff --git a/packages/stack-kms-vault/src/lib.rs b/packages/stack-kms-vault/src/lib.rs new file mode 100644 index 000000000..3e7440f3e --- /dev/null +++ b/packages/stack-kms-vault/src/lib.rs @@ -0,0 +1,135 @@ +#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +//! The HashiCorp Vault Transit key vendor backend for Stack Encrypt +//! (ADR-0013, ADR-0014). +//! +//! [`VaultTransit`] is a small, hand-written client for the Transit secrets +//! engine: `encrypt`, `decrypt`, `rewrap` and a key read. It sends through +//! a [`VendorTransport`](stack_kms_vendor::VendorTransport) and implements +//! [`KeyVendorBackend`](stack_kms_vendor::KeyVendorBackend), so +//! [`VendorKeySource`](stack_kms_vendor::VendorKeySource) builds the +//! derived data key mode on it. No Vault SDK crate is used, so this crate +//! also builds for wasm32-wasip1, the Go module's WASI guest. +//! +//! The client calls Vault for keyset records only. One `encrypt` call +//! creates or rewraps a record. One `decrypt` call unwraps the root key of +//! a keyset, once for each root key TTL. The data keys and index keys are +//! derived from the root key locally, so no call is made for each value. +//! +//! # Native Rust +//! +//! ```no_run +//! # async fn example() -> Result<(), Box> { +//! use stack_kms_vault::VaultTransit; +//! use stack_kms_vendor::{KeysetRecord, VendorKeySource}; +//! use stack_kms::KeyMode; +//! +//! // VAULT_ADDR, VAULT_NAMESPACE, and a token from VAULT_TOKEN, AppRole +//! // or Kubernetes auth. +//! let vault = VaultTransit::from_env()?; +//! let record = KeysetRecord::create( +//! &vault, +//! "cipherstash-users", +//! KeyMode::Derived, +//! Some("users".to_owned()), +//! ) +//! .await?; +//! let source = VendorKeySource::new(vault, [record])?; +//! # let _ = source; +//! # Ok(()) +//! # } +//! ``` +//! +//! # Capabilities and limits +//! +//! The [capability matrix](stack_kms_vendor#capability-matrix) of +//! `stack-kms-vendor` compares this backend with ZeroKMS and the other +//! key vendor backends: key lifetime, vendor calls, what the vendor sees +//! and can refuse, credentials, rotation and known limits. +//! +//! # Vault policy +//! +//! The token needs `update` on `/encrypt/` and +//! `/decrypt/`, and (with the default +//! [`KeyDerivation::Detect`]) `read` on `/keys/`. Give it +//! `update` on `/rewrap/` to use [`VaultTransit::rewrap`]. Do +//! not give it `create` on `/encrypt/`: with it, Transit +//! creates a missing key on the first `encrypt`. +//! +//! # The WASI guest +//! +//! The guest builds [`VaultTransit::new`] with its host transport and a +//! [`TokenProvider`] over a host import. Kubernetes auth reads a file and is +//! native only: in the guest, the host's own auth supplies the token. + +#![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 auth; +mod client; +mod config; +mod time; + +pub use auth::{ + AppRoleLogin, AuthError, CachedToken, TokenProvider, VaultAuth, VaultToken, + DEFAULT_APPROLE_MOUNT, DEFAULT_REFRESH_BEFORE_EXPIRY, +}; +#[cfg(not(target_arch = "wasm32"))] +pub use auth::{DefaultAuth, KubernetesLogin}; +pub use client::{ + compact_ciphertext, error_kind, expand_ciphertext, VaultError, VaultTransit, AEAD_KEY_TYPES, + INVALID_CIPHERTEXT_MARKERS, NOT_FOUND_MARKERS, WRAPPED_KEY_FORMAT, +}; +pub use config::{process_env, ConfigError, EnvLookup, KeyDerivation, VaultConfig, DEFAULT_MOUNT}; +pub use time::{system_clock, Clock}; + +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +mod native { + use stack_kms_vendor::HttpVendorTransport; + + use crate::auth::DefaultAuth; + use crate::{VaultConfig, VaultTransit}; + + /// [`VaultTransit::from_env`] failed. + #[derive(Debug, thiserror::Error)] + pub enum FromEnvError { + /// The address, namespace or mount. + #[error(transparent)] + Config(#[from] crate::ConfigError), + /// The auth method. + #[error(transparent)] + Auth(#[from] crate::AuthError), + /// The HTTP client. + #[error(transparent)] + Transport(#[from] stack_kms_vendor::TransportError), + } + + /// The client [`VaultTransit::from_env`] builds. + pub type DefaultVaultTransit = + VaultTransit>; + + impl DefaultVaultTransit { + /// A client configured from the environment: the address, namespace + /// and mount from [`VaultConfig::from_env`], and the auth method + /// from [`DefaultAuth::from_env`]. Native only. + pub fn from_env() -> Result { + let config = VaultConfig::from_env()?; + let auth = DefaultAuth::from_env(&config, HttpVendorTransport::new()?)?; + Ok(VaultTransit::new(config, HttpVendorTransport::new()?, auth)) + } + } +} + +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +pub use native::{DefaultVaultTransit, FromEnvError}; diff --git a/packages/stack-kms-vault/src/time.rs b/packages/stack-kms-vault/src/time.rs new file mode 100644 index 000000000..ce4ab058e --- /dev/null +++ b/packages/stack-kms-vault/src/time.rs @@ -0,0 +1,5 @@ +//! The time source of a client. + +/// The time source of a token cache: `SystemTime::now` unless a test sets +/// another. +pub use stack_kms_vendor::{system_clock, Clock}; diff --git a/packages/stack-kms-vault/tasks.toml b/packages/stack-kms-vault/tasks.toml new file mode 100644 index 000000000..9fda7a454 --- /dev/null +++ b/packages/stack-kms-vault/tasks.toml @@ -0,0 +1,25 @@ +# 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-vault"] +description = "Build docs for stack-kms-vault with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-kms-vault --no-deps --all-features" + +# The Vault Transit backend against a running `vault server -dev` +# (tests/vault_dev.rs). The test skips itself when STACK_KMS_VAULT_ADDR is +# unset, which is right for `cargo nextest run --workspace` and wrong here: a +# skip would read as a pass. So this task refuses to run without it. +# tests-crates.yml's `vault` job runs Vault as a service and calls this task. +# +# docker run -d --rm -p 8200:8200 -e VAULT_DEV_ROOT_TOKEN_ID=root hashicorp/vault:1.20 +# STACK_KMS_VAULT_ADDR=http://127.0.0.1:8200 mise run test:vault:stack-kms-vault +["test:vault:stack-kms-vault"] +description = "Run the stack-kms-vault tests against vault server -dev (needs STACK_KMS_VAULT_ADDR)" +run = """ +if [ -z "${STACK_KMS_VAULT_ADDR:-}" ]; then + echo "error: set STACK_KMS_VAULT_ADDR (for example http://127.0.0.1:8200)" >&2 + exit 1 +fi +mise x --env test -- cargo nextest run --locked -p stack-kms-vault --all-features --test vault_dev --no-tests=fail +""" diff --git a/packages/stack-kms-vault/tests/common/mod.rs b/packages/stack-kms-vault/tests/common/mod.rs new file mode 100644 index 000000000..8358f91a6 --- /dev/null +++ b/packages/stack-kms-vault/tests/common/mod.rs @@ -0,0 +1,336 @@ +//! Shared test support: the fixture address, token and keys, and an +//! in-process emulation of the Vault Transit API. The fixture, replay and +//! recording harness is `stack_kms_vendor::replay`, shared by every vendor +//! crate. + +#![allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use base64ct::{Base64, Encoding}; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use stack_kms_vault::{VaultConfig, VaultToken, VaultTransit}; +use stack_kms_vendor::replay::body_json; +// Each test binary uses a different part of the harness. +#[allow(unused_imports)] +pub use stack_kms_vendor::replay::{ + Fixture, FixtureRequest, FixtureResponse, Fixtures, Recording, Replay, +}; +use stack_kms_vendor::{TransportError, VendorRequest, VendorResponse, VendorTransport}; + +/// The address of every fixture: a local `vault server -dev`. +pub const ADDRESS: &str = "http://127.0.0.1:8200"; +/// The dev root token of every fixture. +pub const TOKEN: &str = "fixture-root-token"; +/// The non-derived Transit key of the fixtures. +pub const KEY: &str = "cipherstash-fixture"; +/// The derived Transit key of the fixtures. +pub const DERIVED_KEY: &str = "cipherstash-fixture-derived"; + +/// The fixtures of this crate. +pub const FIXTURES: Fixtures = + Fixtures::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")); + +pub fn fixture_path(name: &str) -> String { + FIXTURES.path(name) +} + +pub fn load(name: &str) -> T { + FIXTURES.load(name) +} + +/// A client over `transport` with the fixture address and token. +pub fn fixture_client( + transport: T, +) -> VaultTransit { + VaultTransit::new( + VaultConfig::new(ADDRESS).unwrap(), + transport, + VaultToken::new(TOKEN), + ) +} + +#[derive(Clone)] +struct EmulatedKey { + key: [u8; 32], + derived: bool, + key_type: &'static str, +} + +/// An in-process emulation of the Vault Transit API: key reads, `encrypt`, +/// `decrypt` and `rewrap` with `batch_input`, derived keys (which require +/// `context`, while other keys ignore it, as Vault does), AppRole and +/// Kubernetes logins, and Vault's error shapes. It checks the token and +/// the namespace on every request. +/// +/// The contract suite uses random keys and ids, so recorded bytes cannot +/// serve it: this answers it instead. Clones share one state. +#[derive(Clone, Default)] +pub struct TransitEmulator { + keys: Arc>>, + tokens: Arc>>, + logins: Arc>, + login_lease: Arc>, + namespace: Arc>>, + requests: Arc>>, + sleeps: Arc>>, +} + +impl TransitEmulator { + /// An emulator holding the non-derived [`KEY`] and the derived + /// [`DERIVED_KEY`], that accepts [`TOKEN`]. + pub fn new() -> Self { + let emulator = Self::default(); + emulator.add_key(KEY, false, "aes256-gcm96"); + emulator.add_key(DERIVED_KEY, true, "aes256-gcm96"); + emulator.tokens.lock().unwrap().push(TOKEN.to_owned()); + *emulator.login_lease.lock().unwrap() = 3600; + emulator + } + + pub fn add_key(&self, name: &str, derived: bool, key_type: &'static str) { + let mut key = [0u8; 32]; + for (i, byte) in key.iter_mut().enumerate() { + *byte = (i as u8) ^ name.len() as u8 ^ u8::from(derived); + } + self.keys.lock().unwrap().insert( + name.to_owned(), + EmulatedKey { + key, + derived, + key_type, + }, + ); + } + + /// Require the `X-Vault-Namespace` header `namespace`. + pub fn with_namespace(self, namespace: &str) -> Self { + *self.namespace.lock().unwrap() = Some(namespace.to_owned()); + self + } + + /// Revoke every token. + pub fn revoke_tokens(&self) { + self.tokens.lock().unwrap().clear(); + } + + pub fn logins(&self) -> usize { + *self.logins.lock().unwrap() + } + + /// Every request: method, path after `/v1/`, body. + pub fn requests(&self) -> Vec<(String, String, Value)> { + self.requests.lock().unwrap().clone() + } + + pub fn sleeps(&self) -> Vec { + self.sleeps.lock().unwrap().clone() + } + + fn error(status: u16, message: &str) -> VendorResponse { + VendorResponse::new( + status, + json!({ "errors": [message] }).to_string().into_bytes(), + ) + .header("content-type", "application/json") + } + + fn ok(data: Value) -> VendorResponse { + VendorResponse::new( + 200, + json!({ "request_id": "emulated", "data": data }) + .to_string() + .into_bytes(), + ) + .header("content-type", "application/json") + } + + fn cipher(key: &EmulatedKey, context: Option<&str>) -> Result { + let key_bytes = if key.derived { + let context = context.filter(|c| !c.is_empty()).ok_or( + "missing 'context' for key derivation; the key was created using a derived key", + )?; + let mut hasher = Sha256::new(); + hasher.update(key.key); + hasher.update( + Base64::decode_vec(context).map_err(|_| "failed to base64-decode context")?, + ); + hasher.finalize().into() + } else { + key.key + }; + Ok(Aes256Gcm::new_from_slice(&key_bytes).unwrap()) + } + + fn encrypt(key: &EmulatedKey, item: &Value, counter: &mut u8) -> Value { + let cipher = match Self::cipher(key, item["context"].as_str()) { + Ok(cipher) => cipher, + Err(error) => return json!({ "error": error }), + }; + let Ok(plaintext) = Base64::decode_vec(item["plaintext"].as_str().unwrap_or("")) else { + return json!({ "error": "failed to base64-decode plaintext" }); + }; + *counter = counter.wrapping_add(1); + let nonce = [*counter; 12]; + let mut out = nonce.to_vec(); + out.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), plaintext.as_slice()) + .unwrap(), + ); + json!({ "ciphertext": format!("vault:v1:{}", Base64::encode_string(&out)), "key_version": 1 }) + } + + fn decrypt(key: &EmulatedKey, item: &Value) -> Result, String> { + let cipher = Self::cipher(key, item["context"].as_str())?; + let text = item["ciphertext"].as_str().unwrap_or(""); + let encoded = text + .strip_prefix("vault:v1:") + .ok_or("invalid ciphertext: no prefix")?; + let bytes = Base64::decode_vec(encoded) + .map_err(|_| "invalid ciphertext: could not decode base64")?; + if bytes.len() < 12 { + return Err("invalid ciphertext length".into()); + } + let (nonce, ct) = bytes.split_at(12); + cipher + .decrypt(Nonce::from_slice(nonce), ct) + .map_err(|_| "cipher: message authentication failed".into()) + } + + fn login(&self, path: &str, body: &Value) -> VendorResponse { + let ok = match path { + "auth/approle/login" => { + body["role_id"] == "fixture-role-id" && body["secret_id"] == "fixture-secret-id" + } + "auth/kubernetes/login" => { + body["role"] == "cipherstash" && body["jwt"] == "fixture.jwt.token" + } + _ => return Self::error(404, "no handler for route"), + }; + if !ok { + return Self::error(400, "invalid role or secret ID"); + } + let mut logins = self.logins.lock().unwrap(); + *logins += 1; + let token = format!("hvs.emulated-{logins}"); + self.tokens.lock().unwrap().push(token.clone()); + VendorResponse::new( + 200, + json!({ + "auth": { + "client_token": token, + "lease_duration": *self.login_lease.lock().unwrap(), + "renewable": true + } + }) + .to_string() + .into_bytes(), + ) + } +} + +impl VendorTransport for TransitEmulator { + async fn send(&self, request: VendorRequest) -> Result { + let path = request + .url + .split_once("/v1/") + .map(|(_, path)| path.to_owned()) + .expect("a /v1/ URL"); + let body = body_json(&request.body); + self.requests + .lock() + .unwrap() + .push((request.method.clone(), path.clone(), body.clone())); + let namespace = self.namespace.lock().unwrap().clone(); + assert_eq!( + request.header_value("x-vault-namespace").map(str::to_owned), + namespace, + "the namespace header" + ); + assert_eq!(request.header_value("x-vault-request"), Some("true")); + if path.starts_with("auth/") { + return Ok(self.login(&path, &body)); + } + let token = request + .header_value("x-vault-token") + .unwrap_or("") + .to_owned(); + if !self.tokens.lock().unwrap().contains(&token) { + return Ok(Self::error(403, "permission denied")); + } + let mut parts = path.splitn(3, '/'); + let (Some("transit"), Some(operation), Some(name)) = + (parts.next(), parts.next(), parts.next()) + else { + return Ok(Self::error(404, "no handler for route")); + }; + let key = self.keys.lock().unwrap().get(name).cloned(); + if operation == "keys" && request.method == "GET" { + let Some(key) = key else { + return Ok(Self::error(404, "")); + }; + return Ok(Self::ok(json!({ + "name": name, + "derived": key.derived, + "type": key.key_type, + "supports_encryption": true, + "supports_decryption": true, + "latest_version": 1 + }))); + } + let Some(key) = key else { + return Ok(Self::error(400, "encryption key not found")); + }; + let items = body["batch_input"].as_array().cloned().unwrap_or_default(); + let has_context = items.iter().filter(|i| i.get("context").is_some()).count(); + if has_context != 0 && has_context != items.len() { + return Ok(Self::error( + 400, + "context should be set either in all the request blocks or in none", + )); + } + let mut counter = 0u8; + let results: Vec = match operation { + "encrypt" => items + .iter() + .map(|i| Self::encrypt(&key, i, &mut counter)) + .collect(), + "decrypt" => items + .iter() + .map(|i| match Self::decrypt(&key, i) { + Ok(plaintext) => json!({ "plaintext": Base64::encode_string(&plaintext) }), + Err(error) => json!({ "plaintext": "", "error": error }), + }) + .collect(), + "rewrap" => items + .iter() + .map(|i| match Self::decrypt(&key, i) { + Ok(plaintext) => { + let mut item = i.clone(); + item["plaintext"] = Value::String(Base64::encode_string(&plaintext)); + Self::encrypt(&key, &item, &mut counter) + } + Err(error) => json!({ "error": error }), + }) + .collect(), + _ => return Ok(Self::error(404, "no handler for route")), + }; + let failed = results.iter().any(|r| r.get("error").is_some()); + let mut response = Self::ok(json!({ "batch_results": results })); + if failed { + response.status = 400; + } + Ok(response) + } + + async fn sleep(&self, duration: Duration) { + self.sleeps.lock().unwrap().push(duration); + } +} diff --git a/packages/stack-kms-vault/tests/contract.rs b/packages/stack-kms-vault/tests/contract.rs new file mode 100644 index 000000000..22a5c38d5 --- /dev/null +++ b/packages/stack-kms-vault/tests/contract.rs @@ -0,0 +1,236 @@ +//! The shared contract suite, run against the Vault Transit backend over an +//! in-process emulation of the Transit API, for a derived and a +//! non-derived Transit key. +//! +//! The suite generates random keys and keyset ids, so recorded exchanges +//! cannot answer it; the emulator does (see `common::TransitEmulator`). The +//! request shapes are pinned by `tests/fixtures.rs`, and a real Vault is +//! exercised by `tests/vault_dev.rs`. + +mod common; + +use common::{fixture_client, TransitEmulator, ADDRESS, DERIVED_KEY, KEY, TOKEN}; +use stack_kms::{BackendErrorKind, Error, KeyMode, KeyVendor}; +use stack_kms_vault::{KeyDerivation, VaultConfig, VaultToken, VaultTransit}; +use stack_kms_vendor::derive::record_key_aad; +use stack_kms_vendor::{contract, KeyVendorBackend}; +use uuid::Uuid; +use vitaminc::protected::{Controlled, Protected}; + +fn client_with( + emulator: &TransitEmulator, + derivation: KeyDerivation, +) -> VaultTransit { + VaultTransit::new( + VaultConfig::new(ADDRESS) + .unwrap() + .with_key_derivation(derivation), + emulator.clone(), + VaultToken::new(TOKEN), + ) +} + +fn kind_of(error: Error) -> BackendErrorKind { + match error { + Error::Backend { backend, kind, .. } => { + assert_eq!(backend.name(), "vault-transit"); + kind + } + other => panic!("not a backend error: {other:?}"), + } +} + +#[tokio::test] +async fn vault_transit_meets_the_contract_with_a_non_derived_key() { + let emulator = TransitEmulator::new(); + contract::run(|| client_with(&emulator, KeyDerivation::Detect), KEY).await; +} + +#[tokio::test] +async fn vault_transit_meets_the_contract_with_a_derived_key() { + let emulator = TransitEmulator::new(); + contract::run( + || client_with(&emulator, KeyDerivation::Detect), + DERIVED_KEY, + ) + .await; +} + +#[tokio::test] +async fn vault_transit_meets_the_contract_with_the_derivation_configured() { + let emulator = TransitEmulator::new(); + contract::run( + || client_with(&emulator, KeyDerivation::Derived), + DERIVED_KEY, + ) + .await; + contract::run(|| client_with(&emulator, KeyDerivation::NotDerived), KEY).await; +} + +#[tokio::test] +async fn a_wrapped_key_opens_only_under_its_own_aad() { + let emulator = TransitEmulator::new(); + // A non-derived key configured as derived: Vault ignores the context, + // so only the bound key refuses other associated data. + for (key, derivation) in [ + (KEY, KeyDerivation::Detect), + (DERIVED_KEY, KeyDerivation::Detect), + (KEY, KeyDerivation::Derived), + ] { + let vault = client_with(&emulator, derivation); + let aad = record_key_aad(Uuid::from_u128(1), KeyVendor::Vault, KeyMode::Derived); + let wrapped = vault + .wrap(key, &Protected::new([9u8; 32]), &aad) + .await + .unwrap(); + assert_eq!( + vault.unwrap(key, &wrapped, &aad).await.unwrap().risky_ref(), + &[9u8; 32] + ); + let other = record_key_aad(Uuid::from_u128(2), KeyVendor::Vault, KeyMode::Derived); + let error = vault.unwrap(key, &wrapped, &other).await.unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::InvalidCiphertext, "{key}"); + } +} + +#[tokio::test] +async fn a_derived_key_gets_the_aad_as_context_and_another_key_gets_none() { + let emulator = TransitEmulator::new(); + let vault = client_with(&emulator, KeyDerivation::Detect); + let aad = b"the associated data"; + let _ = vault + .wrap(DERIVED_KEY, &Protected::new([1u8; 32]), aad) + .await + .unwrap(); + let _ = vault + .wrap(KEY, &Protected::new([1u8; 32]), aad) + .await + .unwrap(); + let requests = emulator.requests(); + let encrypts: Vec<_> = requests + .iter() + .filter(|(_, path, _)| path.starts_with("transit/encrypt/")) + .collect(); + assert_eq!(encrypts.len(), 2); + assert_eq!( + encrypts[0].2["batch_input"][0]["context"], + "dGhlIGFzc29jaWF0ZWQgZGF0YQ==" + ); + assert!(encrypts[1].2["batch_input"][0].get("context").is_none()); + // The key's derivation is read once per key. + let reads = requests + .iter() + .filter(|(method, path, _)| method == "GET" && path.starts_with("transit/keys/")) + .count(); + assert_eq!(reads, 2); + let _ = vault + .wrap(KEY, &Protected::new([1u8; 32]), aad) + .await + .unwrap(); + assert_eq!( + emulator + .requests() + .iter() + .filter(|(method, _, _)| method == "GET") + .count(), + 2 + ); +} + +#[tokio::test] +async fn detect_refuses_a_missing_key_and_a_key_that_is_not_an_aead() { + let emulator = TransitEmulator::new(); + emulator.add_key("rsa", false, "rsa-2048"); + let vault = client_with(&emulator, KeyDerivation::Detect); + let error = vault + .wrap("no-such-key", &Protected::new([1u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::NotFound); + let error = vault + .wrap("rsa", &Protected::new([1u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::AccessDenied); + // Nothing was encrypted. + assert!(emulator + .requests() + .iter() + .all(|(_, path, _)| !path.contains("/encrypt/"))); +} + +#[tokio::test] +async fn a_key_reference_that_is_not_a_key_name_is_refused_before_any_call() { + let emulator = TransitEmulator::new(); + let vault = client_with(&emulator, KeyDerivation::NotDerived); + for bad in ["", "a/b", "..", "a?b", "a b", "../../sys/seal"] { + let error = vault + .wrap(bad, &Protected::new([1u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::NotFound, "{bad:?}"); + } + assert!(emulator.requests().is_empty()); +} + +#[tokio::test] +async fn a_wrapped_key_that_is_not_one_is_refused_before_any_call() { + let emulator = TransitEmulator::new(); + let vault = client_with(&emulator, KeyDerivation::NotDerived); + for bad in [&b""[..], b"vault:v1:AAAA", &[2, 0, 0, 0, 1, 7]] { + let error = vault.unwrap(KEY, bad, b"a").await.unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::InvalidCiphertext); + } + assert!(emulator.requests().is_empty()); +} + +#[tokio::test] +async fn rewrap_keeps_the_key_and_its_binding() { + let emulator = TransitEmulator::new(); + for key in [KEY, DERIVED_KEY] { + let vault = client_with(&emulator, KeyDerivation::Detect); + let wrapped = vault + .wrap(key, &Protected::new([4u8; 32]), b"aad") + .await + .unwrap(); + let rewrapped = vault.rewrap(key, &wrapped, b"aad").await.unwrap(); + assert_eq!( + vault + .unwrap(key, &rewrapped, b"aad") + .await + .unwrap() + .risky_ref(), + &[4u8; 32] + ); + assert!(vault.unwrap(key, &rewrapped, b"other").await.is_err()); + } +} + +#[tokio::test] +async fn the_namespace_is_sent_on_every_request() { + let emulator = TransitEmulator::new().with_namespace("admin/team-a"); + let vault = VaultTransit::new( + VaultConfig::new(ADDRESS) + .unwrap() + .with_namespace("admin/team-a") + .unwrap(), + emulator.clone(), + VaultToken::new(TOKEN), + ); + let wrapped = vault + .wrap(KEY, &Protected::new([3u8; 32]), b"a") + .await + .unwrap(); + let _ = vault.unwrap(KEY, &wrapped, b"a").await.unwrap(); + // The emulator asserts the header on each request. + assert_eq!(emulator.requests().len(), 3); +} + +#[tokio::test] +async fn the_backend_reports_vault_and_sleeps_on_its_transport() { + let emulator = TransitEmulator::new(); + let vault = fixture_client(emulator.clone()); + assert_eq!(vault.vendor(), KeyVendor::Vault); + vault.sleep(std::time::Duration::from_millis(50)).await; + assert_eq!(emulator.sleeps(), [std::time::Duration::from_millis(50)]); +} diff --git a/packages/stack-kms-vault/tests/fixtures.rs b/packages/stack-kms-vault/tests/fixtures.rs new file mode 100644 index 000000000..425458a6d --- /dev/null +++ b/packages/stack-kms-vault/tests/fixtures.rs @@ -0,0 +1,357 @@ +//! Each Vault call and each error mapping, against HTTP exchanges recorded +//! from `vault server -dev` 1.20 (`tests/fixtures/*.json`). +//! +//! Every fixture was recorded by `tests/vault_dev.rs`'s +//! `records_the_fixtures`, except `kubernetes_login.json` (a dev server has +//! no Kubernetes; the response is the AppRole login response in the +//! Kubernetes auth shape) and the errors whose name says "written by hand". +//! Each request side is pinned: the URL, the headers and the body, which +//! includes the bound key and the context. + +mod common; + +use common::{fixture_client, load, Fixture, FixtureResponse, Replay, DERIVED_KEY, KEY, TOKEN}; +use serde::Deserialize; +use stack_kms::{BackendErrorKind, Error, KeyMode, KeyVendor}; +use stack_kms_vault::{ + AppRoleLogin, AuthError, CachedToken, KeyDerivation, KubernetesLogin, TokenProvider, + VaultConfig, VaultToken, VaultTransit, +}; +use stack_kms_vendor::derive::record_key_aad; +use stack_kms_vendor::{ + KeyVendorBackend, TransportError, VendorRequest, VendorResponse, VendorTransport, +}; +use uuid::Uuid; +use vitaminc::protected::{Controlled, Protected}; + +const KEYSET: Uuid = Uuid::from_u128(0x0f1e2d3c_4b5a_6978_8796_a5b4c3d2e1f0); + +fn record_aad() -> Vec { + record_key_aad(KEYSET, KeyVendor::Vault, KeyMode::Derived) +} + +fn kind_of(error: Error) -> BackendErrorKind { + match error { + Error::Backend { backend, kind, .. } => { + assert_eq!(backend.name(), "vault-transit"); + kind + } + other => panic!("not a backend error: {other:?}"), + } +} + +fn replay(name: &str) -> Replay { + Replay::new().expect_all(load(name)) +} + +/// The stored wrapped key of the `index`th ciphertext a fixture's last +/// response returns. +fn wrapped_of(name: &str, index: usize) -> Vec { + let fixtures: Vec = load(name); + let body = &fixtures.last().unwrap().response.body; + let text = body["data"]["batch_results"][index]["ciphertext"] + .as_str() + .unwrap(); + stack_kms_vault::compact_ciphertext(text).unwrap() +} + +async fn check_single(prefix: &str, key: &str) { + let vault = fixture_client(replay(&format!("{prefix}encrypt.json"))); + let wrapped = vault + .wrap(key, &Protected::new([0x42u8; 32]), &record_aad()) + .await + .unwrap(); + assert_eq!(wrapped, wrapped_of(&format!("{prefix}encrypt.json"), 0)); + assert!(vault.transport().is_done()); + + let vault = fixture_client(replay(&format!("{prefix}decrypt.json"))); + let opened = vault.unwrap(key, &wrapped, &record_aad()).await.unwrap(); + assert_eq!(opened.risky_ref(), &[0x42u8; 32]); + assert!(vault.transport().is_done()); +} + +#[tokio::test] +async fn encrypt_and_decrypt_with_a_non_derived_key() { + check_single("", KEY).await; +} + +#[tokio::test] +async fn encrypt_and_decrypt_with_a_derived_key() { + check_single("derived_", DERIVED_KEY).await; +} + +#[tokio::test] +async fn the_fixture_ciphertexts_name_the_key_version() { + let fixtures: Vec = load("encrypt.json"); + let text = fixtures[1].response.body["data"]["batch_results"][0]["ciphertext"] + .as_str() + .unwrap(); + assert!(text.starts_with("vault:v1:")); +} + +#[tokio::test] +async fn a_decrypt_under_other_associated_data_is_refused_by_the_bound_key() { + // Vault decrypts it (a non-derived key has no context to check); the + // bound key does not open under other associated data. + let wrapped = wrapped_of("encrypt.json", 0); + let other = record_key_aad(Uuid::from_u128(2), KeyVendor::Vault, KeyMode::Derived); + let fixtures: Vec = load("decrypt.json"); + let vault = VaultTransit::new( + VaultConfig::new(common::ADDRESS) + .unwrap() + .with_key_derivation(KeyDerivation::NotDerived), + Replay::new().answer(fixtures[1].response.to_response()), + VaultToken::new(TOKEN), + ); + let error = vault.unwrap(KEY, &wrapped, &other).await.unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::InvalidCiphertext); +} + +#[tokio::test] +async fn rewrap_moves_to_the_latest_version() { + let fixtures: Vec = load("rewrap.json"); + let text = fixtures[0].request.body["batch_input"][0]["ciphertext"] + .as_str() + .unwrap(); + let wrapped = stack_kms_vault::compact_ciphertext(text).unwrap(); + let vault = VaultTransit::new( + VaultConfig::new(common::ADDRESS) + .unwrap() + .with_key_derivation(KeyDerivation::NotDerived), + Replay::new().expect_all(fixtures), + VaultToken::new(TOKEN), + ); + let rewrapped = vault.rewrap(KEY, &wrapped, &record_aad()).await.unwrap(); + assert_eq!(&wrapped[1..5], &1u32.to_be_bytes()); + assert_eq!(&rewrapped[1..5], &2u32.to_be_bytes()); +} + +#[tokio::test] +async fn approle_login() { + let login = AppRoleLogin::new( + &VaultConfig::new(common::ADDRESS).unwrap(), + replay("approle_login.json"), + "fixture-role-id", + "fixture-secret-id", + ); + let token = login.token().await.unwrap(); + assert!(token.token().starts_with("hvs.")); + assert_eq!(token.lease(), Some(std::time::Duration::from_secs(20 * 60))); + assert!(login.transport().is_done()); +} + +#[tokio::test] +async fn kubernetes_login_reads_the_token_file_and_sends_the_namespace() { + let path = std::env::temp_dir().join(format!("stack-kms-vault-jwt-{}", Uuid::new_v4())); + std::fs::write(&path, "fixture.jwt.token\n").unwrap(); + let config = VaultConfig::new(common::ADDRESS) + .unwrap() + .with_namespace("admin") + .unwrap(); + let login = KubernetesLogin::new(&config, replay("kubernetes_login.json"), "cipherstash") + .with_token_path(&path); + let token = login.token().await.unwrap(); + assert_eq!(token.token(), "hvs.fixture-kubernetes-token"); + assert_eq!(token.lease(), Some(std::time::Duration::from_secs(3600))); + std::fs::remove_file(&path).unwrap(); + + let error = login.token().await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + assert!(!error.to_string().contains("fixture.jwt.token")); +} + +#[tokio::test] +async fn a_refused_login_is_access_denied_and_says_why() { + let login = AppRoleLogin::new( + &VaultConfig::new(common::ADDRESS).unwrap(), + Replay::new().answer(VendorResponse::new( + 400, + br#"{"errors":["invalid role or secret ID"]}"#.to_vec(), + )), + "role", + "the-secret-id", + ); + let error = login.token().await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + assert!(error.to_string().contains("invalid role or secret ID")); + assert!(!error.to_string().contains("the-secret-id")); + + for (status, kind) in [ + (429, BackendErrorKind::Throttled), + (503, BackendErrorKind::Unavailable), + ] { + let login = AppRoleLogin::new( + &VaultConfig::new(common::ADDRESS).unwrap(), + Replay::new().answer(VendorResponse::new(status, b"{}".to_vec())), + "role", + "secret", + ); + assert_eq!(login.token().await.unwrap_err().kind(), kind); + } +} + +#[derive(Deserialize)] +struct ErrorCase { + name: String, + response: FixtureResponse, + kind: String, +} + +#[tokio::test] +async fn every_recorded_error_maps_to_its_kind() { + let cases: Vec = load("errors.json"); + assert!(cases.len() >= 12); + let wrapped = wrapped_of("encrypt.json", 0); + for case in cases { + let vault = VaultTransit::new( + VaultConfig::new(common::ADDRESS) + .unwrap() + .with_key_derivation(KeyDerivation::NotDerived), + Replay::new().answer(case.response.to_response()), + VaultToken::new(TOKEN), + ); + let error = vault + .unwrap(KEY, &wrapped, &record_aad()) + .await + .unwrap_err(); + let message = format!("{error:?}"); + assert_eq!( + format!("{:?}", kind_of(error)), + case.kind, + "{}: {message}", + case.name + ); + } +} + +/// A transport that never gets a response. +struct Down; + +impl VendorTransport for Down { + async fn send(&self, _request: VendorRequest) -> Result { + Err(TransportError("connection refused".into())) + } + + async fn sleep(&self, _duration: std::time::Duration) {} +} + +#[tokio::test] +async fn no_response_is_unavailable() { + let error = fixture_client(Down) + .unwrap(KEY, &wrapped_of("encrypt.json", 0), &record_aad()) + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Unavailable); +} + +struct NoToken; + +impl TokenProvider for NoToken { + async fn token(&self) -> Result { + Err(AuthError::access_denied("none configured")) + } +} + +#[tokio::test] +async fn no_token_is_access_denied_and_sends_nothing() { + let vault = VaultTransit::new( + VaultConfig::new(common::ADDRESS).unwrap(), + Replay::new(), + NoToken, + ); + let error = vault + .wrap(KEY, &Protected::new([1u8; 32]), &record_aad()) + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::AccessDenied); +} + +#[tokio::test] +async fn an_unreadable_success_is_unavailable() { + let wrapped = wrapped_of("encrypt.json", 0); + for body in [ + b"not json".to_vec(), + br#"{"data":null}"#.to_vec(), + br#"{"data":{"batch_results":[]}}"#.to_vec(), + br#"{"data":{"batch_results":[{"plaintext":"not base64!"}]}}"#.to_vec(), + br#"{"data":{"batch_results":[{}]}}"#.to_vec(), + ] { + let vault = VaultTransit::new( + VaultConfig::new(common::ADDRESS) + .unwrap() + .with_key_derivation(KeyDerivation::NotDerived), + Replay::new().answer(VendorResponse::new(200, body)), + VaultToken::new(TOKEN), + ); + let error = vault + .unwrap(KEY, &wrapped, &record_aad()) + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Unavailable); + } + for body in [ + br#"{"data":{"batch_results":[{"ciphertext":"not a vault ciphertext"}]}}"#.to_vec(), + br#"{"data":{"batch_results":[{}]}}"#.to_vec(), + // Two results for one item. + br#"{"data":{"batch_results":[{"ciphertext":"vault:v1:AAAA"},{"ciphertext":"vault:v1:AAAA"}]}}"#.to_vec(), + ] { + let vault = VaultTransit::new( + VaultConfig::new(common::ADDRESS) + .unwrap() + .with_key_derivation(KeyDerivation::NotDerived), + Replay::new().answer(VendorResponse::new(200, body)), + VaultToken::new(TOKEN), + ); + let error = vault + .wrap(KEY, &Protected::new([1u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Unavailable); + } +} + +#[tokio::test] +async fn a_plaintext_that_is_not_a_bound_key_is_refused() { + // 32 bytes: a bare key, not a bound key this crate wrapped. + let body = br#"{"data":{"batch_results":[{"plaintext":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="}]}}"#; + let vault = VaultTransit::new( + VaultConfig::new(common::ADDRESS) + .unwrap() + .with_key_derivation(KeyDerivation::NotDerived), + Replay::new().answer(VendorResponse::new(200, body.to_vec())), + VaultToken::new(TOKEN), + ); + let error = vault + .unwrap(KEY, &wrapped_of("encrypt.json", 0), &record_aad()) + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::InvalidCiphertext); +} + +#[tokio::test] +async fn a_403_drops_the_cached_token() { + let emulator = common::TransitEmulator::new(); + let config = VaultConfig::new(common::ADDRESS).unwrap(); + let vault = VaultTransit::new( + config.clone(), + emulator.clone(), + CachedToken::new(AppRoleLogin::new( + &config, + emulator.clone(), + "fixture-role-id", + "fixture-secret-id", + )), + ); + let wrapped = vault + .wrap(KEY, &Protected::new([2u8; 32]), b"a") + .await + .unwrap(); + assert_eq!(emulator.logins(), 1); + emulator.revoke_tokens(); + let error = vault.unwrap(KEY, &wrapped, b"a").await.unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::AccessDenied); + // The next call logs in again. + let opened = vault.unwrap(KEY, &wrapped, b"a").await.unwrap(); + assert_eq!(opened.risky_ref(), &[2u8; 32]); + assert_eq!(emulator.logins(), 2); +} diff --git a/packages/stack-kms-vault/tests/fixtures/approle_login.json b/packages/stack-kms-vault/tests/fixtures/approle_login.json new file mode 100644 index 000000000..758079222 --- /dev/null +++ b/packages/stack-kms-vault/tests/fixtures/approle_login.json @@ -0,0 +1,63 @@ +[ + { + "request": { + "method": "POST", + "url": "http://127.0.0.1:8200/v1/auth/approle/login", + "headers": [ + [ + "content-type", + "application/json" + ], + [ + "x-vault-request", + "true" + ] + ], + "body": { + "role_id": "fixture-role-id", + "secret_id": "fixture-secret-id" + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "body": { + "auth": { + "accessor": "HjtgtYyKbCqHkdof10qacF2T", + "client_token": "hvs.fixture-approle-token", + "entity_id": "829b5b18-c7ea-dc88-fb8e-d062e4bc22b9", + "lease_duration": 1200, + "metadata": { + "role_name": "cipherstash-fixture" + }, + "mfa_requirement": null, + "num_uses": 0, + "orphan": true, + "policies": [ + "cipherstash-fixture", + "default" + ], + "renewable": true, + "token_policies": [ + "cipherstash-fixture", + "default" + ], + "token_type": "service" + }, + "data": null, + "lease_duration": 0, + "lease_id": "", + "mount_type": "", + "renewable": false, + "request_id": "0d334ea8-3d6c-570f-9d25-a6e781cab3c7", + "warnings": null, + "wrap_info": null + } + } + } +] diff --git a/packages/stack-kms-vault/tests/fixtures/decrypt.json b/packages/stack-kms-vault/tests/fixtures/decrypt.json new file mode 100644 index 000000000..8b9bac882 --- /dev/null +++ b/packages/stack-kms-vault/tests/fixtures/decrypt.json @@ -0,0 +1,113 @@ +[ + { + "request": { + "method": "GET", + "url": "http://127.0.0.1:8200/v1/transit/keys/cipherstash-fixture", + "headers": [ + [ + "x-vault-token", + "fixture-root-token" + ], + [ + "x-vault-request", + "true" + ] + ], + "body": null + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "body": { + "auth": null, + "data": { + "allow_plaintext_backup": false, + "auto_rotate_period": 0, + "deletion_allowed": false, + "derived": false, + "exportable": false, + "imported_key": false, + "keys": { + "1": 1794453020 + }, + "latest_version": 1, + "min_available_version": 0, + "min_decryption_version": 1, + "min_encryption_version": 0, + "name": "cipherstash-fixture", + "supports_decryption": true, + "supports_derivation": true, + "supports_encryption": true, + "supports_signing": false, + "type": "aes256-gcm96" + }, + "lease_duration": 0, + "lease_id": "", + "mount_type": "transit", + "renewable": false, + "request_id": "265456e4-9458-3d7e-be55-9f89e114cf25", + "warnings": null, + "wrap_info": null + } + } + }, + { + "request": { + "method": "POST", + "url": "http://127.0.0.1:8200/v1/transit/decrypt/cipherstash-fixture", + "headers": [ + [ + "x-vault-token", + "fixture-root-token" + ], + [ + "x-vault-request", + "true" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "batch_input": [ + { + "ciphertext": "vault:v1:EvgR1diKqRQ5U3zHlQ0xcPgxzW7i2A18Eb9IRIfFlCE9PDG03iljQLF7BJR8Dpb0z+UtzRyrUg1a6jqeh2bU7yqxkMju9xzcBOC+O3GnLOYvCrjR8L4T1o9fz8e1" + } + ] + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "body": { + "auth": null, + "data": { + "batch_results": [ + { + "plaintext": "AevXOIqrrKsZlViO6KECUTACqz90/AVaZD9scv8R47JCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=", + "reference": "" + } + ] + }, + "lease_duration": 0, + "lease_id": "", + "mount_type": "transit", + "renewable": false, + "request_id": "f4fbc87e-d82f-30ce-5419-f7edd964e4c7", + "warnings": null, + "wrap_info": null + } + } + } +] diff --git a/packages/stack-kms-vault/tests/fixtures/derived_decrypt.json b/packages/stack-kms-vault/tests/fixtures/derived_decrypt.json new file mode 100644 index 000000000..1c814440c --- /dev/null +++ b/packages/stack-kms-vault/tests/fixtures/derived_decrypt.json @@ -0,0 +1,116 @@ +[ + { + "request": { + "method": "GET", + "url": "http://127.0.0.1:8200/v1/transit/keys/cipherstash-fixture-derived", + "headers": [ + [ + "x-vault-token", + "fixture-root-token" + ], + [ + "x-vault-request", + "true" + ] + ], + "body": null + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "body": { + "auth": null, + "data": { + "allow_plaintext_backup": false, + "auto_rotate_period": 0, + "convergent_encryption": false, + "deletion_allowed": false, + "derived": true, + "exportable": false, + "imported_key": false, + "kdf": "hkdf_sha256", + "keys": { + "1": 1794453020 + }, + "latest_version": 1, + "min_available_version": 0, + "min_decryption_version": 1, + "min_encryption_version": 0, + "name": "cipherstash-fixture-derived", + "supports_decryption": true, + "supports_derivation": true, + "supports_encryption": true, + "supports_signing": false, + "type": "aes256-gcm96" + }, + "lease_duration": 0, + "lease_id": "", + "mount_type": "transit", + "renewable": false, + "request_id": "23c985c0-238f-69e7-b15f-2cbe7e64c219", + "warnings": null, + "wrap_info": null + } + } + }, + { + "request": { + "method": "POST", + "url": "http://127.0.0.1:8200/v1/transit/decrypt/cipherstash-fixture-derived", + "headers": [ + [ + "x-vault-token", + "fixture-root-token" + ], + [ + "x-vault-request", + "true" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "batch_input": [ + { + "ciphertext": "vault:v1:pIBm6LS1rTj/jSRuccdWftK0Yv5umbyfMz6EWP5P9ZyMuBzbDzkmRU4+jdQJvr8Ni9Nz0+/lKgt1zroHJA3L0AYiYHIHONt8m7dRY2KMLPa6mFQIWaj7SSpouVDf", + "context": "BQAAAAAAAAAlAAAAAAAAAHN0YWNrLWttcy12ZW5kb3Iva2V5c2V0LXJlY29yZC1rZXkvdjEIAAAAAAAAAHJvb3Qta2V5EAAAAAAAAAAPHi08S1ppeIeWpbTD0uHwAQAAAAAAAAAEAQAAAAAAAAAC" + } + ] + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "body": { + "auth": null, + "data": { + "batch_results": [ + { + "plaintext": "AevXOIqrrKsZlViO6KECUTACqz90/AVaZD9scv8R47JCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=", + "reference": "" + } + ] + }, + "lease_duration": 0, + "lease_id": "", + "mount_type": "transit", + "renewable": false, + "request_id": "e6ca0339-4806-cdd1-75c6-c5c6053103f3", + "warnings": null, + "wrap_info": null + } + } + } +] diff --git a/packages/stack-kms-vault/tests/fixtures/derived_encrypt.json b/packages/stack-kms-vault/tests/fixtures/derived_encrypt.json new file mode 100644 index 000000000..9ebd7ae75 --- /dev/null +++ b/packages/stack-kms-vault/tests/fixtures/derived_encrypt.json @@ -0,0 +1,117 @@ +[ + { + "request": { + "method": "GET", + "url": "http://127.0.0.1:8200/v1/transit/keys/cipherstash-fixture-derived", + "headers": [ + [ + "x-vault-token", + "fixture-root-token" + ], + [ + "x-vault-request", + "true" + ] + ], + "body": null + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "body": { + "auth": null, + "data": { + "allow_plaintext_backup": false, + "auto_rotate_period": 0, + "convergent_encryption": false, + "deletion_allowed": false, + "derived": true, + "exportable": false, + "imported_key": false, + "kdf": "hkdf_sha256", + "keys": { + "1": 1794453020 + }, + "latest_version": 1, + "min_available_version": 0, + "min_decryption_version": 1, + "min_encryption_version": 0, + "name": "cipherstash-fixture-derived", + "supports_decryption": true, + "supports_derivation": true, + "supports_encryption": true, + "supports_signing": false, + "type": "aes256-gcm96" + }, + "lease_duration": 0, + "lease_id": "", + "mount_type": "transit", + "renewable": false, + "request_id": "9d413b71-4a74-c3cf-3d42-d6a992043d27", + "warnings": null, + "wrap_info": null + } + } + }, + { + "request": { + "method": "POST", + "url": "http://127.0.0.1:8200/v1/transit/encrypt/cipherstash-fixture-derived", + "headers": [ + [ + "x-vault-token", + "fixture-root-token" + ], + [ + "x-vault-request", + "true" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "batch_input": [ + { + "context": "BQAAAAAAAAAlAAAAAAAAAHN0YWNrLWttcy12ZW5kb3Iva2V5c2V0LXJlY29yZC1rZXkvdjEIAAAAAAAAAHJvb3Qta2V5EAAAAAAAAAAPHi08S1ppeIeWpbTD0uHwAQAAAAAAAAAEAQAAAAAAAAAC", + "plaintext": "AevXOIqrrKsZlViO6KECUTACqz90/AVaZD9scv8R47JCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=" + } + ] + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "body": { + "auth": null, + "data": { + "batch_results": [ + { + "ciphertext": "vault:v1:pIBm6LS1rTj/jSRuccdWftK0Yv5umbyfMz6EWP5P9ZyMuBzbDzkmRU4+jdQJvr8Ni9Nz0+/lKgt1zroHJA3L0AYiYHIHONt8m7dRY2KMLPa6mFQIWaj7SSpouVDf", + "key_version": 1, + "reference": "" + } + ] + }, + "lease_duration": 0, + "lease_id": "", + "mount_type": "transit", + "renewable": false, + "request_id": "40f4d2ad-5c4a-1158-2bc8-3a0d58a9a483", + "warnings": null, + "wrap_info": null + } + } + } +] diff --git a/packages/stack-kms-vault/tests/fixtures/encrypt.json b/packages/stack-kms-vault/tests/fixtures/encrypt.json new file mode 100644 index 000000000..dd1c7ec07 --- /dev/null +++ b/packages/stack-kms-vault/tests/fixtures/encrypt.json @@ -0,0 +1,114 @@ +[ + { + "request": { + "method": "GET", + "url": "http://127.0.0.1:8200/v1/transit/keys/cipherstash-fixture", + "headers": [ + [ + "x-vault-token", + "fixture-root-token" + ], + [ + "x-vault-request", + "true" + ] + ], + "body": null + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "body": { + "auth": null, + "data": { + "allow_plaintext_backup": false, + "auto_rotate_period": 0, + "deletion_allowed": false, + "derived": false, + "exportable": false, + "imported_key": false, + "keys": { + "1": 1794453020 + }, + "latest_version": 1, + "min_available_version": 0, + "min_decryption_version": 1, + "min_encryption_version": 0, + "name": "cipherstash-fixture", + "supports_decryption": true, + "supports_derivation": true, + "supports_encryption": true, + "supports_signing": false, + "type": "aes256-gcm96" + }, + "lease_duration": 0, + "lease_id": "", + "mount_type": "transit", + "renewable": false, + "request_id": "79f3eac8-ca6b-cf84-419b-7e74295de888", + "warnings": null, + "wrap_info": null + } + } + }, + { + "request": { + "method": "POST", + "url": "http://127.0.0.1:8200/v1/transit/encrypt/cipherstash-fixture", + "headers": [ + [ + "x-vault-token", + "fixture-root-token" + ], + [ + "x-vault-request", + "true" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "batch_input": [ + { + "plaintext": "AevXOIqrrKsZlViO6KECUTACqz90/AVaZD9scv8R47JCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=" + } + ] + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "body": { + "auth": null, + "data": { + "batch_results": [ + { + "ciphertext": "vault:v1:EvgR1diKqRQ5U3zHlQ0xcPgxzW7i2A18Eb9IRIfFlCE9PDG03iljQLF7BJR8Dpb0z+UtzRyrUg1a6jqeh2bU7yqxkMju9xzcBOC+O3GnLOYvCrjR8L4T1o9fz8e1", + "key_version": 1, + "reference": "" + } + ] + }, + "lease_duration": 0, + "lease_id": "", + "mount_type": "transit", + "renewable": false, + "request_id": "f7fcbb6c-d080-87ca-1dda-058cdb15f047", + "warnings": null, + "wrap_info": null + } + } + } +] diff --git a/packages/stack-kms-vault/tests/fixtures/errors.json b/packages/stack-kms-vault/tests/fixtures/errors.json new file mode 100644 index 000000000..0df8b694a --- /dev/null +++ b/packages/stack-kms-vault/tests/fixtures/errors.json @@ -0,0 +1,289 @@ +[ + { + "kind": "InvalidCiphertext", + "name": "decrypt under another context", + "response": { + "body": { + "auth": null, + "data": { + "batch_results": [ + { + "error": "cipher: message authentication failed", + "plaintext": "", + "reference": "" + } + ] + }, + "lease_duration": 0, + "lease_id": "", + "mount_type": "", + "renewable": false, + "request_id": "3d7b1d8d-57af-8910-f284-1242fd2df87c", + "warnings": null, + "wrap_info": null + }, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "status": 400 + } + }, + { + "kind": "InvalidCiphertext", + "name": "a key version that is too new", + "response": { + "body": { + "auth": null, + "data": { + "batch_results": [ + { + "error": "invalid ciphertext: version is too new", + "plaintext": "", + "reference": "" + } + ] + }, + "lease_duration": 0, + "lease_id": "", + "mount_type": "", + "renewable": false, + "request_id": "7d99e008-a905-abce-1fd5-c6409d737251", + "warnings": null, + "wrap_info": null + }, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "status": 400 + } + }, + { + "kind": "InvalidCiphertext", + "name": "a ciphertext that is too short", + "response": { + "body": { + "auth": null, + "data": { + "batch_results": [ + { + "error": "invalid ciphertext length", + "plaintext": "", + "reference": "" + } + ] + }, + "lease_duration": 0, + "lease_id": "", + "mount_type": "", + "renewable": false, + "request_id": "4350d912-0652-624f-b30d-84d70962295c", + "warnings": null, + "wrap_info": null + }, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "status": 400 + } + }, + { + "kind": "NotFound", + "name": "decrypt with a key that does not exist", + "response": { + "body": { + "errors": [ + "encryption key not found" + ] + }, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "status": 400 + } + }, + { + "kind": "NotFound", + "name": "read a key that does not exist", + "response": { + "body": { + "errors": [] + }, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "status": 404 + } + }, + { + "kind": "AccessDenied", + "name": "a derived key with no context", + "response": { + "body": { + "auth": null, + "data": { + "batch_results": [ + { + "error": "missing 'context' for key derivation; the key was created using a derived key, which means additional, per-request information must be included in order to perform operations with the key", + "reference": "" + } + ] + }, + "lease_duration": 0, + "lease_id": "", + "mount_type": "", + "renewable": false, + "request_id": "f0c099e7-45ad-988d-2fd1-e75a6baa6f2c", + "warnings": null, + "wrap_info": null + }, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "status": 400 + } + }, + { + "kind": "AccessDenied", + "name": "a bad token", + "response": { + "body": { + "errors": [ + "2 errors occurred:\n\t* permission denied\n\t* invalid token\n\n" + ] + }, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "status": 403 + } + }, + { + "kind": "NotFound", + "name": "a mount that does not exist", + "response": { + "body": { + "errors": [ + "no handler for route \"no-such-mount/decrypt/cipherstash-fixture\". route entry not found." + ] + }, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "status": 404 + } + }, + { + "kind": "Throttled", + "name": "a rate limit quota (written by hand)", + "response": { + "body": { + "errors": [ + "request path \"transit/decrypt/cipherstash-fixture\": rate limit quota exceeded" + ] + }, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "status": 429 + } + }, + { + "kind": "Unavailable", + "name": "a sealed Vault (written by hand)", + "response": { + "body": { + "errors": [ + "Vault is sealed" + ] + }, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "status": 503 + } + }, + { + "kind": "Unavailable", + "name": "an internal error (written by hand)", + "response": { + "body": { + "errors": [ + "internal error" + ] + }, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "status": 500 + } + }, + { + "kind": "AccessDenied", + "name": "a policy that denies (written by hand)", + "response": { + "body": { + "errors": [ + "1 error occurred:\n\t* permission denied\n\n" + ] + }, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "status": 403 + } + }, + { + "kind": "InvalidCiphertext", + "name": "a retired key version (written by hand)", + "response": { + "body": { + "errors": [ + "ciphertext version is disallowed by policy (too old)" + ] + }, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "status": 400 + } + } +] diff --git a/packages/stack-kms-vault/tests/fixtures/kubernetes_login.json b/packages/stack-kms-vault/tests/fixtures/kubernetes_login.json new file mode 100644 index 000000000..536339f6d --- /dev/null +++ b/packages/stack-kms-vault/tests/fixtures/kubernetes_login.json @@ -0,0 +1,71 @@ +[ + { + "request": { + "method": "POST", + "url": "http://127.0.0.1:8200/v1/auth/kubernetes/login", + "headers": [ + [ + "content-type", + "application/json" + ], + [ + "x-vault-request", + "true" + ], + [ + "x-vault-namespace", + "admin" + ] + ], + "body": { + "role": "cipherstash", + "jwt": "fixture.jwt.token" + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "body": { + "auth": { + "accessor": "HjtgtYyKbCqHkdof10qacF2T", + "client_token": "hvs.fixture-kubernetes-token", + "entity_id": "829b5b18-c7ea-dc88-fb8e-d062e4bc22b9", + "lease_duration": 3600, + "metadata": { + "role": "cipherstash", + "service_account_name": "app", + "service_account_namespace": "default", + "service_account_secret_name": "", + "service_account_uid": "5b1c2c9f-0000-4000-8000-000000000001" + }, + "mfa_requirement": null, + "num_uses": 0, + "orphan": true, + "policies": [ + "cipherstash-fixture", + "default" + ], + "renewable": true, + "token_policies": [ + "cipherstash-fixture", + "default" + ], + "token_type": "service" + }, + "data": null, + "lease_duration": 0, + "lease_id": "", + "mount_type": "", + "renewable": false, + "request_id": "0d334ea8-3d6c-570f-9d25-a6e781cab3c7", + "warnings": null, + "wrap_info": null + } + } + } +] diff --git a/packages/stack-kms-vault/tests/fixtures/rewrap.json b/packages/stack-kms-vault/tests/fixtures/rewrap.json new file mode 100644 index 000000000..f951dcaba --- /dev/null +++ b/packages/stack-kms-vault/tests/fixtures/rewrap.json @@ -0,0 +1,57 @@ +[ + { + "request": { + "method": "POST", + "url": "http://127.0.0.1:8200/v1/transit/rewrap/cipherstash-fixture", + "headers": [ + [ + "x-vault-token", + "fixture-root-token" + ], + [ + "x-vault-request", + "true" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "batch_input": [ + { + "ciphertext": "vault:v1:B8Ml/QTZ57adlJVybdCDSu0iJgVTPRNP4ZOj/zjy3Wcf10C7zjefJURY1SdVpAjFmXA+BBq3CAMtzQeGW42ZJ0G/BJ40JdkaF8gOCMACuVE6yand1+n/a/6c19V2" + } + ] + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json" + ] + ], + "body": { + "auth": null, + "data": { + "batch_results": [ + { + "ciphertext": "vault:v2:ccW2g83FKwqDAw7NQoxVmdZvXBYXJk9IKJT/upHifUtCRwcOakItzXox+L2pSNSgBMZFpwtzaqBgz8G9Bram5TCKaBRPMn4NtZd4gBmZRZExT3tbv44JAG/SrvOQ", + "key_version": 2, + "reference": "" + } + ] + }, + "lease_duration": 0, + "lease_id": "", + "mount_type": "transit", + "renewable": false, + "request_id": "544ba5f0-4a9d-a3cf-96ee-abce49ac9c7a", + "warnings": null, + "wrap_info": null + } + } + } +] diff --git a/packages/stack-kms-vault/tests/vault_dev.rs b/packages/stack-kms-vault/tests/vault_dev.rs new file mode 100644 index 000000000..e48c032ee --- /dev/null +++ b/packages/stack-kms-vault/tests/vault_dev.rs @@ -0,0 +1,633 @@ +//! The Vault Transit backend against a real `vault server -dev`, over the +//! native transport. +//! +//! Runs only when `STACK_KMS_VAULT_ADDR` is set (for example +//! `http://127.0.0.1:8200`); otherwise each test passes at once and says it +//! was skipped. The token is `STACK_KMS_VAULT_TOKEN` (default `root`). CI +//! runs Vault as a service and sets both. +//! +//! ```sh +//! docker run -d --rm -p 8200:8200 -e VAULT_DEV_ROOT_TOKEN_ID=root hashicorp/vault:1.20 +//! STACK_KMS_VAULT_ADDR=http://127.0.0.1:8200 \ +//! cargo nextest run -p stack-kms-vault --all-features --test vault_dev +//! ``` +//! +//! `records_the_fixtures` writes `tests/fixtures/*.json` again. It runs +//! only when `STACK_KMS_VAULT_RECORD_FIXTURES=1` is also set, against a dev +//! server at `common::ADDRESS` whose root token is `common::TOKEN`: +//! +//! ```sh +//! docker run -d --rm -p 8200:8200 -e VAULT_DEV_ROOT_TOKEN_ID=fixture-root-token hashicorp/vault:1.20 +//! STACK_KMS_VAULT_ADDR=http://127.0.0.1:8200 STACK_KMS_VAULT_TOKEN=fixture-root-token \ +//! STACK_KMS_VAULT_RECORD_FIXTURES=1 \ +//! cargo nextest run -p stack-kms-vault --all-features --test vault_dev records_the_fixtures +//! ``` + +#![cfg(not(target_arch = "wasm32"))] + +mod common; + +use common::{Fixture, Recording, DERIVED_KEY, KEY}; +use serde_json::{json, Value}; +use stack_kms::{BackendErrorKind, Error, KeyMode, KeyVendor}; +use stack_kms_vault::{ + AppRoleLogin, CachedToken, KeyDerivation, TokenProvider, VaultConfig, VaultToken, VaultTransit, +}; +use stack_kms_vendor::derive::record_key_aad; +use stack_kms_vendor::{contract, HttpVendorTransport, KeyVendorBackend}; +use uuid::Uuid; +use vitaminc::protected::{Controlled, Protected}; +use zeroize::Zeroizing; + +const ADDR_VAR: &str = "STACK_KMS_VAULT_ADDR"; +const TOKEN_VAR: &str = "STACK_KMS_VAULT_TOKEN"; +const RECORD_VAR: &str = "STACK_KMS_VAULT_RECORD_FIXTURES"; + +type Client = VaultTransit; + +struct Server { + address: String, + token: String, +} + +fn server() -> Option { + let address = std::env::var(ADDR_VAR).ok().filter(|e| !e.is_empty()); + let Some(address) = address else { + eprintln!("skipped: set {ADDR_VAR} to run against vault server -dev"); + return None; + }; + let token = std::env::var(TOKEN_VAR) + .ok() + .filter(|e| !e.is_empty()) + .unwrap_or_else(|| "root".to_owned()); + Some(Server { address, token }) +} + +fn config(server: &Server) -> VaultConfig { + VaultConfig::new(&server.address).unwrap() +} + +fn client(server: &Server) -> Client { + VaultTransit::new( + config(server), + HttpVendorTransport::new().unwrap(), + VaultToken::new(&server.token), + ) +} + +fn body(value: Value) -> Option>> { + Some(Zeroizing::new(serde_json::to_vec(&value).unwrap())) +} + +/// Send an admin request, ignoring a failure (the mount or key may exist). +async fn admin(vault: &Client, method: &str, path: &str, value: Option) -> Option { + vault + .send_json::(method, path, value.and_then(body)) + .await + .ok() +} + +/// Enable the Transit engine (if needed) and create a key. +async fn create_key(vault: &Client, name: &str, derived: bool) { + let _ = admin( + vault, + "POST", + "sys/mounts/transit", + Some(json!({"type": "transit"})), + ) + .await; + let _ = admin( + vault, + "POST", + &format!("transit/keys/{name}"), + Some(json!({"derived": derived})), + ) + .await; +} + +fn kind(error: Error) -> BackendErrorKind { + match error { + Error::Backend { kind, .. } => kind, + other => panic!("{other:?}"), + } +} + +fn unique(prefix: &str) -> String { + format!("{prefix}-{}", Uuid::new_v4().simple()) +} + +#[tokio::test] +async fn vault_transit_meets_the_contract_on_vault_dev() { + let Some(server) = server() else { return }; + let admin_client = client(&server); + for derived in [false, true] { + let key = unique(if derived { + "contract-derived" + } else { + "contract" + }); + create_key(&admin_client, &key, derived).await; + let make = || { + VaultTransit::new( + config(&server), + HttpVendorTransport::new().unwrap(), + VaultToken::new(&server.token), + ) + }; + contract::run(make, &key).await; + } +} + +#[tokio::test] +async fn vault_dev_errors_map_to_kinds() { + let Some(server) = server() else { return }; + let vault = client(&server); + let key = unique("errors"); + let derived = unique("errors-derived"); + create_key(&vault, &key, false).await; + create_key(&vault, &derived, true).await; + let aad = record_key_aad(Uuid::new_v4(), KeyVendor::Vault, KeyMode::Derived); + let wrapped = vault + .wrap(&derived, &Protected::new([8u8; 32]), &aad) + .await + .unwrap(); + assert_eq!( + vault + .unwrap(&derived, &wrapped, &aad) + .await + .unwrap() + .risky_ref(), + &[8u8; 32] + ); + + // Another context: Vault itself refuses it for a derived key. + let other = record_key_aad(Uuid::new_v4(), KeyVendor::Vault, KeyMode::Derived); + let error = vault.unwrap(&derived, &wrapped, &other).await.unwrap_err(); + assert_eq!(kind(error), BackendErrorKind::InvalidCiphertext); + + // A damaged ciphertext. + let mut damaged = wrapped.clone(); + if let Some(last) = damaged.last_mut() { + *last ^= 1; + } + let error = vault.unwrap(&derived, &damaged, &aad).await.unwrap_err(); + assert_eq!(kind(error), BackendErrorKind::InvalidCiphertext); + + // A key version that does not exist yet. + let mut future = wrapped.clone(); + future[1..5].copy_from_slice(&9u32.to_be_bytes()); + let error = vault.unwrap(&derived, &future, &aad).await.unwrap_err(); + assert_eq!(kind(error), BackendErrorKind::InvalidCiphertext); + + // Another key's ciphertext. + let error = vault.unwrap(&key, &wrapped, &aad).await.unwrap_err(); + assert_eq!(kind(error), BackendErrorKind::InvalidCiphertext); + + // A key that does not exist. + let error = vault + .wrap(&unique("missing"), &Protected::new([1u8; 32]), &aad) + .await + .unwrap_err(); + assert_eq!(kind(error), BackendErrorKind::NotFound); + + // A derived key told it is not derived. + let not_derived = VaultTransit::new( + config(&server).with_key_derivation(KeyDerivation::NotDerived), + HttpVendorTransport::new().unwrap(), + VaultToken::new(&server.token), + ); + let error = not_derived + .wrap(&derived, &Protected::new([1u8; 32]), &aad) + .await + .unwrap_err(); + assert_eq!(kind(error), BackendErrorKind::AccessDenied); + + // A bad token. + let bad = VaultTransit::new( + config(&server), + HttpVendorTransport::new().unwrap(), + VaultToken::new("not-a-token"), + ); + let error = bad + .wrap(&key, &Protected::new([1u8; 32]), &aad) + .await + .unwrap_err(); + assert_eq!(kind(error), BackendErrorKind::AccessDenied); + + // A mount that does not exist. + let no_mount = VaultTransit::new( + config(&server).with_mount("no-such-mount").unwrap(), + HttpVendorTransport::new().unwrap(), + VaultToken::new(&server.token), + ); + let error = no_mount + .wrap(&key, &Protected::new([1u8; 32]), &aad) + .await + .unwrap_err(); + assert_eq!(kind(error), BackendErrorKind::NotFound); +} + +#[tokio::test] +async fn rotation_and_rewrap_on_vault_dev() { + let Some(server) = server() else { return }; + let vault = client(&server); + let key = unique("rotate"); + create_key(&vault, &key, true).await; + let wrapped = vault + .wrap(&key, &Protected::new([6u8; 32]), b"aad") + .await + .unwrap(); + let _ = admin(&vault, "POST", &format!("transit/keys/{key}/rotate"), None).await; + // The old version still opens; rewrap moves it to the new one. + assert_eq!( + vault + .unwrap(&key, &wrapped, b"aad") + .await + .unwrap() + .risky_ref(), + &[6u8; 32] + ); + let rewrapped = vault.rewrap(&key, &wrapped, b"aad").await.unwrap(); + assert_eq!(&rewrapped[1..5], &2u32.to_be_bytes()); + assert_eq!( + vault + .unwrap(&key, &rewrapped, b"aad") + .await + .unwrap() + .risky_ref(), + &[6u8; 32] + ); +} + +/// Enable AppRole auth and make a role that may use `key`; its role id and +/// a secret id. With `fixed`, the ids are the fixtures' own. +async fn approle(vault: &Client, role: &str, key: &str, fixed: bool) -> (String, String) { + let _ = admin( + vault, + "POST", + "sys/auth/approle", + Some(json!({"type": "approle"})), + ) + .await; + let policy = format!( + "path \"transit/encrypt/{key}\" {{ capabilities = [\"update\"] }}\n\ + path \"transit/decrypt/{key}\" {{ capabilities = [\"update\"] }}\n\ + path \"transit/keys/{key}\" {{ capabilities = [\"read\"] }}\n" + ); + let _ = admin( + vault, + "PUT", + &format!("sys/policies/acl/{role}"), + Some(json!({ "policy": policy })), + ) + .await; + let _ = admin( + vault, + "POST", + &format!("auth/approle/role/{role}"), + Some(json!({ "token_policies": [role], "token_ttl": "20m" })), + ) + .await; + if fixed { + let _ = admin( + vault, + "POST", + &format!("auth/approle/role/{role}/role-id"), + Some(json!({ "role_id": "fixture-role-id" })), + ) + .await; + let _ = admin( + vault, + "POST", + &format!("auth/approle/role/{role}/custom-secret-id"), + Some(json!({ "secret_id": "fixture-secret-id" })), + ) + .await; + return ("fixture-role-id".into(), "fixture-secret-id".into()); + } + let role_id = admin( + vault, + "GET", + &format!("auth/approle/role/{role}/role-id"), + None, + ) + .await + .unwrap(); + let secret = admin( + vault, + "POST", + &format!("auth/approle/role/{role}/secret-id"), + Some(json!({})), + ) + .await + .unwrap(); + ( + role_id["data"]["role_id"].as_str().unwrap().to_owned(), + secret["data"]["secret_id"].as_str().unwrap().to_owned(), + ) +} + +#[tokio::test] +async fn approle_login_on_vault_dev() { + let Some(server) = server() else { return }; + let admin_client = client(&server); + let key = unique("approle"); + create_key(&admin_client, &key, false).await; + let role = unique("role"); + let (role_id, secret_id) = approle(&admin_client, &role, &key, false).await; + + let auth = CachedToken::new(AppRoleLogin::new( + &config(&server), + HttpVendorTransport::new().unwrap(), + role_id, + secret_id, + )); + let token = auth.token().await.unwrap(); + assert_eq!(token.lease(), Some(std::time::Duration::from_secs(20 * 60))); + let vault = VaultTransit::new(config(&server), HttpVendorTransport::new().unwrap(), auth); + let aad = record_key_aad(Uuid::new_v4(), KeyVendor::Vault, KeyMode::Derived); + let wrapped = vault + .wrap(&key, &Protected::new([5u8; 32]), &aad) + .await + .unwrap(); + assert_eq!( + vault + .unwrap(&key, &wrapped, &aad) + .await + .unwrap() + .risky_ref(), + &[5u8; 32] + ); + + // A wrong secret id is refused. + let wrong = AppRoleLogin::new( + &config(&server), + HttpVendorTransport::new().unwrap(), + "fixture-role-id-that-does-not-exist", + "nope", + ); + assert_eq!( + wrong.token().await.unwrap_err().kind(), + BackendErrorKind::AccessDenied + ); +} + +/// Write `tests/fixtures/` (an exchange sequence). +fn write(name: &str, exchanges: Vec) { + let text = serde_json::to_string_pretty(&exchanges).unwrap() + "\n"; + std::fs::write(common::fixture_path(name), text).unwrap(); +} + +fn recorded_client( + derivation: KeyDerivation, +) -> VaultTransit, VaultToken> { + VaultTransit::new( + VaultConfig::new(common::ADDRESS) + .unwrap() + .with_key_derivation(derivation), + Recording::new(HttpVendorTransport::new().unwrap()), + VaultToken::new(common::TOKEN), + ) +} + +/// The record key fixtures wrap: a fixed keyset id. +pub const FIXTURE_KEYSET: Uuid = Uuid::from_u128(0x0f1e2d3c_4b5a_6978_8796_a5b4c3d2e1f0); + +#[tokio::test] +async fn records_the_fixtures() { + let Some(server) = server() else { return }; + if std::env::var(RECORD_VAR).as_deref() != Ok("1") { + eprintln!("skipped: set {RECORD_VAR}=1 to record the fixtures"); + return; + } + assert_eq!(server.address.trim_end_matches('/'), common::ADDRESS); + assert_eq!(server.token, common::TOKEN); + let admin_client = client(&server); + create_key(&admin_client, KEY, false).await; + create_key(&admin_client, DERIVED_KEY, true).await; + + let record_aad = record_key_aad(FIXTURE_KEYSET, KeyVendor::Vault, KeyMode::Derived); + + for (key, prefix) in [(KEY, ""), (DERIVED_KEY, "derived_")] { + // One key: a key read, then encrypt. + let vault = recorded_client(KeyDerivation::Detect); + let wrapped = vault + .wrap(key, &Protected::new([0x42u8; 32]), &record_aad) + .await + .unwrap(); + write(&format!("{prefix}encrypt.json"), vault.transport_take()); + let vault = recorded_client(KeyDerivation::Detect); + let opened = vault.unwrap(key, &wrapped, &record_aad).await.unwrap(); + assert_eq!(opened.risky_ref(), &[0x42u8; 32]); + write(&format!("{prefix}decrypt.json"), vault.transport_take()); + } + + // Rewrap after a rotation. + let vault = recorded_client(KeyDerivation::NotDerived); + let wrapped = vault + .wrap(KEY, &Protected::new([0x42u8; 32]), &record_aad) + .await + .unwrap(); + let _ = vault.transport_take(); + let _ = admin( + &admin_client, + "POST", + &format!("transit/keys/{KEY}/rotate"), + None, + ) + .await; + let rewrapped = vault.rewrap(KEY, &wrapped, &record_aad).await.unwrap(); + write("rewrap.json", vec![vault.transport_take().pop().unwrap()]); + let _ = rewrapped; + + // An AppRole login, then a key read and an encrypt with its token. + let _ = approle(&admin_client, "cipherstash-fixture", KEY, true).await; + let login = AppRoleLogin::new( + &VaultConfig::new(common::ADDRESS).unwrap(), + Recording::new(HttpVendorTransport::new().unwrap()), + "fixture-role-id", + "fixture-secret-id", + ); + let _ = login.token().await.unwrap(); + write("approle_login.json", login.transport().take()); + + // Errors, each one exchange. + let mut errors = Vec::new(); + let mut case = |name: &str, kind: &str, exchanges: Vec| { + let exchange = exchanges.into_iter().last().unwrap(); + errors.push(json!({ + "name": name, + "kind": kind, + "response": exchange.response, + })); + }; + let vault = recorded_client(KeyDerivation::NotDerived); + let derived_wrapped = recorded_client(KeyDerivation::Derived) + .wrap(DERIVED_KEY, &Protected::new([1u8; 32]), &record_aad) + .await + .unwrap(); + let other_aad = record_key_aad(Uuid::from_u128(2), KeyVendor::Vault, KeyMode::Derived); + let derived = recorded_client(KeyDerivation::Derived); + assert_eq!( + kind( + derived + .unwrap(DERIVED_KEY, &derived_wrapped, &other_aad) + .await + .unwrap_err() + ), + BackendErrorKind::InvalidCiphertext + ); + case( + "decrypt under another context", + "InvalidCiphertext", + derived.transport_take(), + ); + let mut future = derived_wrapped.clone(); + future[1..5].copy_from_slice(&9u32.to_be_bytes()); + let derived = recorded_client(KeyDerivation::Derived); + assert_eq!( + kind( + derived + .unwrap(DERIVED_KEY, &future, &record_aad) + .await + .unwrap_err() + ), + BackendErrorKind::InvalidCiphertext + ); + case( + "a key version that is too new", + "InvalidCiphertext", + derived.transport_take(), + ); + let short = stack_kms_vault::compact_ciphertext("vault:v1:AAAA").unwrap(); + assert_eq!( + kind(vault.unwrap(KEY, &short, &record_aad).await.unwrap_err()), + BackendErrorKind::InvalidCiphertext + ); + case( + "a ciphertext that is too short", + "InvalidCiphertext", + vault.transport_take(), + ); + assert_eq!( + kind( + vault + .unwrap("no-such-key", &short, &record_aad) + .await + .unwrap_err() + ), + BackendErrorKind::NotFound + ); + case( + "decrypt with a key that does not exist", + "NotFound", + vault.transport_take(), + ); + let detect = recorded_client(KeyDerivation::Detect); + assert_eq!( + kind( + detect + .wrap("no-such-key", &Protected::new([1u8; 32]), &record_aad) + .await + .unwrap_err() + ), + BackendErrorKind::NotFound + ); + case( + "read a key that does not exist", + "NotFound", + detect.transport_take(), + ); + assert_eq!( + kind( + vault + .wrap(DERIVED_KEY, &Protected::new([1u8; 32]), &record_aad) + .await + .unwrap_err() + ), + BackendErrorKind::AccessDenied + ); + case( + "a derived key with no context", + "AccessDenied", + vault.transport_take(), + ); + let bad = VaultTransit::new( + VaultConfig::new(common::ADDRESS) + .unwrap() + .with_key_derivation(KeyDerivation::NotDerived), + Recording::new(HttpVendorTransport::new().unwrap()), + VaultToken::new("not-a-token"), + ); + assert_eq!( + kind(bad.unwrap(KEY, &short, &record_aad).await.unwrap_err()), + BackendErrorKind::AccessDenied + ); + case("a bad token", "AccessDenied", bad.transport_take()); + let no_mount = VaultTransit::new( + VaultConfig::new(common::ADDRESS) + .unwrap() + .with_mount("no-such-mount") + .unwrap() + .with_key_derivation(KeyDerivation::NotDerived), + Recording::new(HttpVendorTransport::new().unwrap()), + VaultToken::new(common::TOKEN), + ); + assert_eq!( + kind(no_mount.unwrap(KEY, &short, &record_aad).await.unwrap_err()), + BackendErrorKind::NotFound + ); + case( + "a mount that does not exist", + "NotFound", + no_mount.transport_take(), + ); + // Responses a dev server does not give, in Vault's shapes. + for (name, status, message, kind) in [ + ( + "a rate limit quota", + 429, + "request path \"transit/decrypt/cipherstash-fixture\": rate limit quota exceeded", + "Throttled", + ), + ("a sealed Vault", 503, "Vault is sealed", "Unavailable"), + ("an internal error", 500, "internal error", "Unavailable"), + ( + "a policy that denies", + 403, + "1 error occurred:\n\t* permission denied\n\n", + "AccessDenied", + ), + ( + "a retired key version", + 400, + "ciphertext version is disallowed by policy (too old)", + "InvalidCiphertext", + ), + ] { + errors.push(json!({ + "name": format!("{name} (written by hand)"), + "kind": kind, + "response": { + "status": status, + "headers": [["content-type", "application/json"]], + "body": { "errors": [message] } + }, + })); + } + std::fs::write( + common::fixture_path("errors.json"), + serde_json::to_string_pretty(&errors).unwrap() + "\n", + ) + .unwrap(); +} + +trait TakeExchanges { + fn transport_take(&self) -> Vec; +} + +impl TakeExchanges for VaultTransit, A> { + fn transport_take(&self) -> Vec { + self.transport().take() + } +} diff --git a/release-plz.toml b/release-plz.toml index 91b8268d7..590d34d3b 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -19,7 +19,7 @@ # feature), so a crates.io release of `eql-bindings` needs the matching # `stack-encrypt` published first. # -# Two more crates are publishable but STAGED: `stack-kms-vendor` and `stack-kms-aws`, the `stack-kms-vendor` version group (the key vendor seam +# Three more crates are publishable but STAGED: `stack-kms-vendor`, `stack-kms-aws` and `stack-kms-vault`, 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 @@ -100,3 +100,10 @@ name = "stack-kms-aws" version_group = "stack-kms-vendor" release = false publish = false + +# Staged: see the header. +[[package]] +name = "stack-kms-vault" +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 056ff1486..4e45e2bc6 100644 --- a/scripts/__tests__/cargo-publish-opt-out.test.mjs +++ b/scripts/__tests__/cargo-publish-opt-out.test.mjs @@ -57,7 +57,7 @@ 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. stack-kms-vendor and stack-kms-aws + // the Go guests' internal ABI and stays unpublished. stack-kms-vendor, stack-kms-aws and stack-kms-vault // are publishable but staged: release-plz.toml keeps them // from releasing until their first version is published by hand (see // release-plz-root-config.test.mjs). @@ -70,6 +70,7 @@ const WORKSPACES = [ 'packages/stack-encrypt-derive', 'packages/stack-kms-vendor', 'packages/stack-kms-aws', + 'packages/stack-kms-vault', ]), expects: 'packages/stack-auth', }, diff --git a/scripts/__tests__/crates-ci.test.mjs b/scripts/__tests__/crates-ci.test.mjs index a33ca8ae4..cf436ead3 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 seven crate `tasks.toml` files the + * 1. Every mise task the set defines — the eight 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, @@ -281,7 +281,7 @@ const CI_EXEMPT_TASKS = new Map([ ]) describe('the scan sees every task mise sees', () => { - it('reads the seven crate task files from the root mise.toml', () => { + it('reads the eight crate task files from the root mise.toml', () => { expect(INCLUDES.sort()).toEqual( [ 'packages/stack-auth/tasks.toml', @@ -289,6 +289,7 @@ describe('the scan sees every task mise sees', () => { 'packages/stack-guest-abi/tasks.toml', 'packages/stack-kms/tasks.toml', 'packages/stack-kms-aws/tasks.toml', + 'packages/stack-kms-vault/tasks.toml', 'packages/stack-kms-vendor/tasks.toml', 'packages/stack-profile/tasks.toml', ].sort(), @@ -340,6 +341,7 @@ describe('the scan sees every task mise sees', () => { 'doc:stack-guest-abi', 'doc:stack-kms', 'doc:stack-kms-aws', + 'doc:stack-kms-vault', '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 fcfdc7136..3c005ab22 100644 --- a/scripts/__tests__/release-plz-root-config.test.mjs +++ b/scripts/__tests__/release-plz-root-config.test.mjs @@ -46,6 +46,7 @@ const PUBLISHED = Object.keys(GROUPS).sort() const STAGED = { 'stack-kms-vendor': 'stack-kms-vendor', 'stack-kms-aws': 'stack-kms-vendor', + 'stack-kms-vault': 'stack-kms-vendor', } const PUBLISHABLE = [...PUBLISHED, ...Object.keys(STAGED)].sort()