From c905ced2b4a5d732b20552a5a8c52776410cd117 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Thu, 12 Nov 2026 16:23:51 +1100 Subject: [PATCH] feat(stack-kms-azure): Azure Key Vault and Managed HSM key vendor backend (CIP-5309) --- .../workflows/integration-azure-key-vault.yml | 88 ++ .github/workflows/tests-crates.yml | 8 +- .github/workflows/udeps.yml | 2 + AGENTS.md | 6 +- Cargo.lock | 22 + Cargo.toml | 1 + SECURITY.md | 6 +- mise.toml | 5 + packages/stack-kms-azure/Cargo.toml | 53 ++ packages/stack-kms-azure/LICENSE | 96 +++ .../stack-kms-azure/src/auth/client_secret.rs | 257 ++++++ .../src/auth/managed_identity.rs | 227 +++++ packages/stack-kms-azure/src/auth/mod.rs | 423 +++++++++ .../src/auth/workload_identity.rs | 105 +++ packages/stack-kms-azure/src/client.rs | 814 ++++++++++++++++++ packages/stack-kms-azure/src/config.rs | 362 ++++++++ packages/stack-kms-azure/src/lib.rs | 165 ++++ packages/stack-kms-azure/src/time.rs | 14 + packages/stack-kms-azure/tasks.toml | 32 + packages/stack-kms-azure/tests/common/mod.rs | 522 +++++++++++ packages/stack-kms-azure/tests/contract.rs | 241 ++++++ packages/stack-kms-azure/tests/fixtures.rs | 714 +++++++++++++++ .../tests/fixtures/app_service_token.json | 31 + .../tests/fixtures/client_secret_token.json | 30 + .../tests/fixtures/errors.json | 167 ++++ .../tests/fixtures/hsm_unwrap.json | 35 + .../tests/fixtures/hsm_wrap.json | 78 ++ .../tests/fixtures/imds_token.json | 34 + .../fixtures/test_only_rsa_key.pkcs8.b64 | 22 + .../tests/fixtures/vault_unwrap.json | 35 + .../tests/fixtures/vault_unwrap_pinned.json | 35 + .../tests/fixtures/vault_wrap.json | 80 ++ .../tests/fixtures/vault_wrap_pinned.json | 35 + .../fixtures/workload_identity_token.json | 30 + packages/stack-kms-azure/tests/live.rs | 119 +++ release-plz.toml | 10 +- .../__tests__/cargo-publish-opt-out.test.mjs | 5 +- scripts/__tests__/crates-ci.test.mjs | 7 +- .../release-plz-root-config.test.mjs | 1 + .../workflow-dispatch-job-conditions.test.mjs | 7 + 40 files changed, 4911 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/integration-azure-key-vault.yml create mode 100644 packages/stack-kms-azure/Cargo.toml create mode 100644 packages/stack-kms-azure/LICENSE create mode 100644 packages/stack-kms-azure/src/auth/client_secret.rs create mode 100644 packages/stack-kms-azure/src/auth/managed_identity.rs create mode 100644 packages/stack-kms-azure/src/auth/mod.rs create mode 100644 packages/stack-kms-azure/src/auth/workload_identity.rs create mode 100644 packages/stack-kms-azure/src/client.rs create mode 100644 packages/stack-kms-azure/src/config.rs create mode 100644 packages/stack-kms-azure/src/lib.rs create mode 100644 packages/stack-kms-azure/src/time.rs create mode 100644 packages/stack-kms-azure/tasks.toml create mode 100644 packages/stack-kms-azure/tests/common/mod.rs create mode 100644 packages/stack-kms-azure/tests/contract.rs create mode 100644 packages/stack-kms-azure/tests/fixtures.rs create mode 100644 packages/stack-kms-azure/tests/fixtures/app_service_token.json create mode 100644 packages/stack-kms-azure/tests/fixtures/client_secret_token.json create mode 100644 packages/stack-kms-azure/tests/fixtures/errors.json create mode 100644 packages/stack-kms-azure/tests/fixtures/hsm_unwrap.json create mode 100644 packages/stack-kms-azure/tests/fixtures/hsm_wrap.json create mode 100644 packages/stack-kms-azure/tests/fixtures/imds_token.json create mode 100644 packages/stack-kms-azure/tests/fixtures/test_only_rsa_key.pkcs8.b64 create mode 100644 packages/stack-kms-azure/tests/fixtures/vault_unwrap.json create mode 100644 packages/stack-kms-azure/tests/fixtures/vault_unwrap_pinned.json create mode 100644 packages/stack-kms-azure/tests/fixtures/vault_wrap.json create mode 100644 packages/stack-kms-azure/tests/fixtures/vault_wrap_pinned.json create mode 100644 packages/stack-kms-azure/tests/fixtures/workload_identity_token.json create mode 100644 packages/stack-kms-azure/tests/live.rs diff --git a/.github/workflows/integration-azure-key-vault.yml b/.github/workflows/integration-azure-key-vault.yml new file mode 100644 index 0000000000..5a02223540 --- /dev/null +++ b/.github/workflows/integration-azure-key-vault.yml @@ -0,0 +1,88 @@ +name: Integration — stack-kms-azure (Azure Key Vault) + +# stack-kms-azure against real Azure Key Vault and, optionally, Managed HSM +# (tests/live.rs). Microsoft publishes no Key Vault emulator, so unlike the +# LocalStack and Vault jobs in tests-crates.yml this needs a real key and real +# credentials. Every other stack-kms-azure test runs in tests-crates.yml, +# against fixtures and an in-process emulator. +# +# OPT-IN. The job runs only when the repository variable +# STACK_KMS_AZURE_LIVE_KEY is set, so the workflow is inert until someone sets +# up the Azure side. To turn it on: +# +# 1. Create an RSA key (2048 bits or more) in a Key Vault, for this test +# only. Optionally, create an oct-HSM AES-256 key in a Managed HSM too +# (a Managed HSM is billed by the hour, so this part is optional). +# 2. Create an app registration (service principal) with a client secret. +# Give it the role "Key Vault Crypto Service Encryption User" on the key +# and nothing else ("Managed HSM Crypto Service Encryption User" on the +# HSM key, if there is one). +# 3. Set the repository variable STACK_KMS_AZURE_LIVE_KEY to the key +# identifier (https://.vault.azure.net/keys/), and, for the +# HSM, STACK_KMS_AZURE_LIVE_HSM_KEY +# (https://.managedhsm.azure.net/keys/). Set the repository +# secrets STACK_KMS_AZURE_TENANT_ID, STACK_KMS_AZURE_CLIENT_ID and +# STACK_KMS_AZURE_CLIENT_SECRET. +# +# Once the variable is set, a missing or empty secret FAILS the job (the +# pre-flight in _test-kms-live.yml): a credentialed job that skips for want +# of a secret reads exactly like one that passes. +# +# The job itself is _test-kms-live.yml, shared with integration-gcp-kms.yml. +# This file keeps the triggers, the opt-in, the fork-PR condition and the +# names of the variables and the secrets. +# +# Fork PRs have no secrets, so the job skips them; every other event runs. +# Enforced by scripts/__tests__/workflow-dispatch-job-conditions.test.mjs. + +on: + push: + branches: [main] + paths: + - packages/stack-kms-azure/** + - packages/stack-kms-vendor/** + - packages/stack-kms/** + - Cargo.lock + - .github/workflows/integration-azure-key-vault.yml + - .github/workflows/_test-kms-live.yml + pull_request: + # Repeated verbatim: GitHub Actions does not support YAML anchors/aliases. + paths: + - packages/stack-kms-azure/** + - packages/stack-kms-vendor/** + - packages/stack-kms/** + - Cargo.lock + - .github/workflows/integration-azure-key-vault.yml + - .github/workflows/_test-kms-live.yml + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + live: + name: stack-kms-azure against Azure Key Vault + if: >- + vars.STACK_KMS_AZURE_LIVE_KEY != '' + && (github.event_name != 'pull_request' + || github.event.pull_request.head.repo.full_name == github.repository) + uses: ./.github/workflows/_test-kms-live.yml + with: + crate: stack-kms-azure + name: stack-kms-azure against Azure Key Vault + live-key-variable: STACK_KMS_AZURE_LIVE_KEY + required-secrets: | + azure-tenant-id STACK_KMS_AZURE_TENANT_ID + azure-client-id STACK_KMS_AZURE_CLIENT_ID + azure-client-secret STACK_KMS_AZURE_CLIENT_SECRET + live-env: | + STACK_KMS_AZURE_LIVE_KEY=${{ vars.STACK_KMS_AZURE_LIVE_KEY }} + STACK_KMS_AZURE_LIVE_HSM_KEY=${{ vars.STACK_KMS_AZURE_LIVE_HSM_KEY }} + secrets: + azure-tenant-id: ${{ secrets.STACK_KMS_AZURE_TENANT_ID }} + azure-client-id: ${{ secrets.STACK_KMS_AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.STACK_KMS_AZURE_CLIENT_SECRET }} diff --git a/.github/workflows/tests-crates.yml b/.github/workflows/tests-crates.yml index d03c758bcf..35909793c5 100644 --- a/.github/workflows/tests-crates.yml +++ b/.github/workflows/tests-crates.yml @@ -24,6 +24,7 @@ on: - packages/stack-kms-aws/** - packages/stack-kms-vault/** - packages/stack-kms-gcp/** + - packages/stack-kms-azure/** - packages/stack-encrypt/** - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** @@ -52,6 +53,7 @@ on: - packages/stack-kms-aws/** - packages/stack-kms-vault/** - packages/stack-kms-gcp/** + - packages/stack-kms-azure/** - packages/stack-encrypt/** - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** @@ -159,15 +161,15 @@ 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, stack-kms-aws, stack-kms-vault and stack-kms-gcp - # are publishable but staged in release-plz.toml (their + # resolves the published versions, which lag the tree. stack-kms-vendor, + # stack-kms-aws, stack-kms-vault, stack-kms-gcp and stack-kms-azure are publishable but staged in release-plz.toml (their # first version is published by hand); packaging them here keeps them # publishable meanwhile. No token. # `--allow-dirty` tolerates files earlier steps leave in the tree. Once a # version is on crates.io, cargo warns rather than fails on a dry run of # it. - name: Verify the stack-* crates package cleanly for crates.io - run: cargo publish --locked --dry-run --allow-dirty -p stack-profile -p stack-auth -p stack-kms -p stack-encrypt-derive -p stack-encrypt -p stack-kms-vendor -p stack-kms-aws -p stack-kms-vault -p stack-kms-gcp + 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 -p stack-kms-gcp -p stack-kms-azure # 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 diff --git a/.github/workflows/udeps.yml b/.github/workflows/udeps.yml index 5da0fb7661..7e1cae70e4 100644 --- a/.github/workflows/udeps.yml +++ b/.github/workflows/udeps.yml @@ -18,6 +18,7 @@ on: - packages/stack-kms-aws/** - packages/stack-kms-vault/** - packages/stack-kms-gcp/** + - packages/stack-kms-azure/** - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** @@ -41,6 +42,7 @@ on: - packages/stack-kms-aws/** - packages/stack-kms-vault/** - packages/stack-kms-gcp/** + - packages/stack-kms-azure/** - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** diff --git a/AGENTS.md b/AGENTS.md index 4aac6867fd..be78df259f 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`, `packages/stack-kms-vault`, `packages/stack-kms-gcp`: The key vendor backends (ADR-0013 and ADR-0014 in `packages/stack-encrypt/docs/adr/`). `stack-kms-vendor` is the vendor seam (`KeyVendorBackend`), keyset records, the derived data key mode, the static key backend and the `VendorTransport` HTTP seam (feature `http`: the native reqwest transport). Its `test-support` feature holds the test code every vendor crate shares: the contract suite (`contract`), `FakeVendor`, and the recorded-fixture harness (`replay`: fixture types, `Replay`, `Recording`); a vendor crate's `tests/common/mod.rs` holds only its own emulator, keys and constants. `stack-kms-aws` is the AWS KMS backend: a hand-written JSON client with SigV4 signing in-crate and the AWS credential providers; its LocalStack test runs in `tests-crates.yml`'s `localstack` job. `stack-kms-vault` is the HashiCorp Vault Transit backend: a hand-written Transit client with token, AppRole and Kubernetes auth; its `vault server -dev` test runs in `tests-crates.yml`'s `vault` job. `stack-kms-gcp` is the Google Cloud KMS backend: a hand-written Cloud KMS REST client with CRC32C checks, service account (RS256 JWT, the pure-Rust `rsa` crate, which has the open advisory RUSTSEC-2023-0071) and metadata server credentials; the service account login is the default feature `service-account`, and a build without it holds no RSA code; Google has no Cloud KMS emulator, so its live test runs only in the opt-in, credentialed `integration-gcp-kms.yml`, which is inert until the `STACK_KMS_GCP_LIVE_KEY` repository variable is set. All four 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`, `packages/stack-kms-gcp`, `packages/stack-kms-azure`: The key vendor backends (ADR-0013 and ADR-0014 in `packages/stack-encrypt/docs/adr/`). `stack-kms-vendor` is the vendor seam (`KeyVendorBackend`), keyset records, the derived data key mode, the static key backend and the `VendorTransport` HTTP seam (feature `http`: the native reqwest transport). Its `test-support` feature holds the test code every vendor crate shares: the contract suite (`contract`), `FakeVendor`, and the recorded-fixture harness (`replay`: fixture types, `Replay`, `Recording`); a vendor crate's `tests/common/mod.rs` holds only its own emulator, keys and constants. `stack-kms-aws` is the AWS KMS backend: a hand-written JSON client with SigV4 signing in-crate and the AWS credential providers; its LocalStack test runs in `tests-crates.yml`'s `localstack` job. `stack-kms-vault` is the HashiCorp Vault Transit backend: a hand-written Transit client with token, AppRole and Kubernetes auth; its `vault server -dev` test runs in `tests-crates.yml`'s `vault` job. `stack-kms-gcp` is the Google Cloud KMS backend: a hand-written Cloud KMS REST client with CRC32C checks, service account (RS256 JWT, the pure-Rust `rsa` crate, which has the open advisory RUSTSEC-2023-0071) and metadata server credentials; the service account login is the default feature `service-account`, and a build without it holds no RSA code; Google has no Cloud KMS emulator, so its live test runs only in the opt-in, credentialed `integration-gcp-kms.yml`, which is inert until the `STACK_KMS_GCP_LIVE_KEY` repository variable is set. `stack-kms-azure` is the Azure Key Vault and Managed HSM backend: a hand-written Key Vault REST client (`wrapkey`/`unwrapkey`, RSA-OAEP-256 or A256KW, the associated data bound by `stack_kms_vendor::bound` because Key Vault has none) with Entra ID client secret, workload identity and managed identity credentials, and no RSA code of its own; Microsoft has no Key Vault emulator, so its live test runs only in the opt-in, credentialed `integration-azure-key-vault.yml`, which is inert until the `STACK_KMS_AZURE_LIVE_KEY` repository variable is set. Both live workflows call one job, `_test-kms-live.yml`; each keeps its triggers, opt-in and fork-PR condition, and its variable and secret names. All five are root workspace members, publishable in the `stack-kms-vendor` version group, and staged in `release-plz.toml` (`release = false`) until their first version is published by hand. The `stack-kms-vendor` crate docs hold the capability matrix of every key backend (key lifetime, vendor calls, what the vendor sees and can refuse, credentials, rotation, known limits); change it with the code it describes. TypeScript (`@cipherstash/stack`) cannot use a key vendor backend yet. - `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. - `languages/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 ten stack-* +- **Three Cargo workspaces, not one.** The root workspace (the eleven 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 nine crate `tasks.toml` files loses its last CI caller. + `mise.toml` or the ten 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 b946f7986e..2422905130 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3646,6 +3646,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "stack-kms-azure" +version = "0.1.0" +dependencies = [ + "aes", + "base64ct", + "rand_core 0.6.4", + "rsa", + "serde", + "serde_json", + "stack-encrypt", + "stack-kms", + "stack-kms-vendor", + "thiserror 1.0.69", + "tokio", + "url", + "uuid", + "vitaminc 0.9.0", + "web-time", + "zeroize", +] + [[package]] name = "stack-kms-gcp" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d06e36c6c5..50cee708c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "packages/stack-kms-aws", "packages/stack-kms-vault", "packages/stack-kms-gcp", + "packages/stack-kms-azure", "packages/stack-encrypt", "packages/stack-encrypt-derive", "packages/stack-guest-abi", diff --git a/SECURITY.md b/SECURITY.md index 9385834a05..20b255b938 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,8 +38,10 @@ 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. -Four more Rust crates, **`stack-kms-vendor`**, **`stack-kms-aws`**, **`stack-kms-vault`** and **`stack-kms-gcp`** -(`packages/stack-kms-vendor`, `packages/stack-kms-aws`, `packages/stack-kms-vault`, `packages/stack-kms-gcp`), are prepared for +Five more Rust crates, **`stack-kms-vendor`**, **`stack-kms-aws`**, +**`stack-kms-vault`**, **`stack-kms-gcp`** and **`stack-kms-azure`** +(`packages/stack-kms-vendor`, `packages/stack-kms-aws`, `packages/stack-kms-vault`, +`packages/stack-kms-gcp`, `packages/stack-kms-azure`), 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 e2d110dde6..f252d73cb1 100644 --- a/mise.toml +++ b/mise.toml @@ -14,6 +14,7 @@ includes = [ "packages/stack-kms-aws/tasks.toml", "packages/stack-kms-vault/tasks.toml", "packages/stack-kms-gcp/tasks.toml", + "packages/stack-kms-azure/tasks.toml", "packages/stack-profile/tasks.toml", "packages/stack-encrypt/tasks.toml", "packages/stack-guest-abi/tasks.toml", @@ -140,6 +141,10 @@ check stack-kms-vault check stack-kms-gcp # Without `service-account`, the form a build with no RSA uses. check stack-kms-gcp --no-default-features +# The Azure Key Vault key vendor backend, default features on: its native +# transport, workload identity and managed identity are gated off on wasm32. +# It does no RSA itself (Azure does). +check stack-kms-azure # 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-azure/Cargo.toml b/packages/stack-kms-azure/Cargo.toml new file mode 100644 index 0000000000..956e9ed4c9 --- /dev/null +++ b/packages/stack-kms-azure/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "stack-kms-azure" +description = "The Azure Key Vault and Managed HSM key vendor backend for Stack Encrypt: a hand-written Key Vault client with Entra ID client secret, workload identity and managed identity credentials, 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"] +# `AzureKeyVault::from_env`: the native transport (`HttpVendorTransport`) and +# the default credentials 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 } +web-time = { 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 } +# The fixture emulator only: RSA-OAEP-256 unwrap with a TEST-ONLY key, and +# AES for an RFC 3394 key wrap. The crate itself does no RSA (Azure does). +rsa = { version = "0.9.10", default-features = false, features = ["std", "sha2"] } +rand_core = { version = "0.6.4", features = ["getrandom"] } +aes = "0.8.4" +tokio = { workspace = true, features = ["rt", "macros", "time"] } +uuid = { workspace = true } +vitaminc = { workspace = true, features = ["protected", "random"] } + +# Builds `AzureKeyVault::from_env`, which needs the native transport. +[[test]] +name = "live" +required-features = ["http"] + +[package.metadata.docs.rs] +all-features = true diff --git a/packages/stack-kms-azure/LICENSE b/packages/stack-kms-azure/LICENSE new file mode 100644 index 0000000000..2cbd67a66d --- /dev/null +++ b/packages/stack-kms-azure/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-azure/src/auth/client_secret.rs b/packages/stack-kms-azure/src/auth/client_secret.rs new file mode 100644 index 0000000000..30ebce2eb9 --- /dev/null +++ b/packages/stack-kms-azure/src/auth/client_secret.rs @@ -0,0 +1,257 @@ +//! The Entra ID token endpoint: a service principal's client secret +//! (OAuth 2.0 client credentials), and the shared request of every login +//! that posts to it. + +use std::fmt; + +use stack_kms_vendor::{VendorRequest, VendorTransport}; +use zeroize::Zeroizing; + +use super::{read_token_response, AccessToken, AuthError, TokenProvider}; +use crate::time::{system_clock, Clock}; + +/// The Entra ID host of the public cloud. `AZURE_AUTHORITY_HOST` (the Azure +/// SDK name) changes it, for example to `https://login.chinacloudapi.cn`. +pub const DEFAULT_AUTHORITY_HOST: &str = "https://login.microsoftonline.com"; + +/// The `client_assertion_type` of a federated token (RFC 7523). +pub const JWT_BEARER_ASSERTION_TYPE: &str = + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + +/// A tenant id: a GUID or a domain name, `[A-Za-z0-9.-]`, so that it cannot +/// change the token endpoint path. +pub fn is_tenant_id(text: &str) -> bool { + !text.is_empty() + && text.len() <= 256 + && text + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-')) + && !text.starts_with('.') +} + +/// An Entra ID application: the tenant, the client id and the authority +/// host. Shared by the logins that post to the token endpoint. +#[derive(Clone, Debug)] +pub(crate) struct EntraApp { + tenant_id: String, + client_id: String, + authority_host: String, +} + +impl EntraApp { + pub(crate) fn new(tenant_id: &str, client_id: &str) -> Result { + if !is_tenant_id(tenant_id) { + return Err(AuthError::access_denied(format!( + "{tenant_id:?} is not an Entra ID tenant id" + ))); + } + if client_id.is_empty() { + return Err(AuthError::access_denied("the client id is empty")); + } + Ok(Self { + tenant_id: tenant_id.to_owned(), + client_id: client_id.to_owned(), + authority_host: DEFAULT_AUTHORITY_HOST.to_owned(), + }) + } + + pub(crate) fn with_authority_host(mut self, host: String) -> Result { + let parsed = url::Url::parse(&host).map_err(|_| { + AuthError::access_denied(format!("{host:?} is not an authority host URL")) + })?; + if parsed.scheme() != "https" + || parsed.host().is_none() + || parsed.path() != "/" + || parsed.query().is_some() + || parsed.fragment().is_some() + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return Err(AuthError::access_denied(format!( + "{host:?} is not an HTTPS authority host with no path" + ))); + } + self.authority_host = parsed.as_str().trim_end_matches('/').to_owned(); + Ok(self) + } + + pub(crate) fn client_id(&self) -> &str { + &self.client_id + } + + pub(crate) fn tenant_id(&self) -> &str { + &self.tenant_id + } + + pub(crate) fn token_url(&self) -> String { + format!( + "{}/{}/oauth2/v2.0/token", + self.authority_host, self.tenant_id + ) + } + + /// Post the client credentials form `extra` (after `grant_type`, + /// `client_id` and `scope`) and read the token. `source` names the login + /// in errors. + pub(crate) async fn post( + &self, + transport: &T, + clock: &Clock, + source: &str, + scope: &str, + extra: &[(&str, &str)], + ) -> Result { + let body = { + let mut form = url::form_urlencoded::Serializer::new(String::new()); + let _ = form + .append_pair("grant_type", "client_credentials") + .append_pair("client_id", &self.client_id) + .append_pair("scope", scope); + for (name, value) in extra { + let _ = form.append_pair(name, value); + } + Zeroizing::new(form.finish().into_bytes()) + }; + let mut request = VendorRequest::new("POST", self.token_url()) + .header("content-type", "application/x-www-form-urlencoded"); + request.body = body; + let response = transport.send(request).await.map_err(|error| { + AuthError::unavailable(format!( + "the Entra ID token endpoint got no response: {error}" + )) + })?; + read_token_response(source, &response, clock()) + } +} + +/// A service principal's client secret, exchanged at the Entra ID token +/// endpoint for an access token (OAuth 2.0 client credentials). Entra ID +/// issues the token for about an hour. Wrap it in a +/// [`CachedToken`](super::CachedToken). +/// +/// The secret is wiped on drop and never printed. Everywhere: the WASI guest +/// can use it if the host passes the secret, but ADR-0014 has the host +/// supply the token. +pub struct ClientSecretLogin { + app: EntraApp, + secret: Zeroizing, + transport: T, + clock: Clock, +} + +impl fmt::Debug for ClientSecretLogin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ClientSecretLogin") + .field("tenant_id", &self.app.tenant_id()) + .field("client_id", &self.app.client_id()) + .field("client_secret", &"") + .finish_non_exhaustive() + } +} + +impl ClientSecretLogin { + /// Log in as `client_id` of `tenant_id` with `secret`, through + /// `transport`. Refuses a tenant id that is not a GUID or domain name. + pub fn new( + tenant_id: &str, + client_id: &str, + secret: impl Into, + transport: T, + ) -> Result { + let secret = Zeroizing::new(secret.into()); + if secret.is_empty() { + return Err(AuthError::access_denied("the client secret is empty")); + } + Ok(Self { + app: EntraApp::new(tenant_id, client_id)?, + secret, + transport, + clock: system_clock(), + }) + } + + /// Post to the Entra ID host `host` (`https://login.microsoftonline.us`) + /// instead of [`DEFAULT_AUTHORITY_HOST`]. + pub fn with_authority_host(mut self, host: impl Into) -> Result { + self.app = self.app.with_authority_host(host.into())?; + Ok(self) + } + + /// Read the time from `clock` (for an `expires_on` response). + pub fn with_clock(mut self, clock: Clock) -> Self { + self.clock = clock; + self + } + + /// The token endpoint URL. + pub fn token_url(&self) -> String { + self.app.token_url() + } + + /// The transport. + pub fn transport(&self) -> &T { + &self.transport + } +} + +impl TokenProvider for ClientSecretLogin { + async fn token(&self, scope: &str) -> Result { + self.app + .post( + &self.transport, + &self.clock, + "the client secret login", + scope, + &[("client_secret", self.secret.as_str())], + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tenant_ids_are_checked() { + for good in [ + "00000000-0000-0000-0000-000000000000", + "contoso.onmicrosoft.com", + "organizations", + ] { + assert!(is_tenant_id(good), "{good}"); + } + for bad in ["", "a/b", "a?b", "a b", ".", "..", "a%2fb", "a#b"] { + assert!(!is_tenant_id(bad), "{bad:?}"); + } + } + + #[test] + fn the_login_is_checked_and_hides_the_secret() { + let login = ClientSecretLogin::new("tenant", "client", "the-secret", ()).unwrap(); + assert_eq!( + login.token_url(), + "https://login.microsoftonline.com/tenant/oauth2/v2.0/token" + ); + assert!(!format!("{login:?}").contains("the-secret")); + let login = login + .with_authority_host("https://login.microsoftonline.us/") + .unwrap(); + assert_eq!( + login.token_url(), + "https://login.microsoftonline.us/tenant/oauth2/v2.0/token" + ); + for bad in [ + "http://login.microsoftonline.com", + "https://login.microsoftonline.com/x", + "https://login.microsoftonline.com/?a=b", + "not a url", + ] { + let login = ClientSecretLogin::new("tenant", "client", "s", ()).unwrap(); + assert!(login.with_authority_host(bad).is_err(), "{bad}"); + } + assert!(ClientSecretLogin::new("a/b", "client", "s", ()).is_err()); + assert!(ClientSecretLogin::new("tenant", "", "s", ()).is_err()); + assert!(ClientSecretLogin::new("tenant", "client", "", ()).is_err()); + } +} diff --git a/packages/stack-kms-azure/src/auth/managed_identity.rs b/packages/stack-kms-azure/src/auth/managed_identity.rs new file mode 100644 index 0000000000..581259e088 --- /dev/null +++ b/packages/stack-kms-azure/src/auth/managed_identity.rs @@ -0,0 +1,227 @@ +//! The managed identity of an Azure resource: IMDS, or the App Service +//! identity endpoint. Native only. + +use std::fmt; + +use stack_kms_vendor::{VendorRequest, VendorTransport}; +use zeroize::Zeroizing; + +use super::{read_token_response, resource_of, AccessToken, AuthError, TokenProvider}; +use crate::config::EnvLookup; +use crate::time::{system_clock, Clock}; + +/// The token URL of the Azure Instance Metadata Service. +pub const IMDS_TOKEN_URL: &str = "http://169.254.169.254/metadata/identity/oauth2/token"; +/// The IMDS API version of a token request. +pub const IMDS_API_VERSION: &str = "2018-02-01"; +/// The API version of an App Service (and Functions) identity endpoint +/// request. +pub const APP_SERVICE_API_VERSION: &str = "2019-08-01"; + +/// Where the managed identity's token comes from. +enum Source { + /// IMDS: Azure VMs, VM scale sets, AKS nodes and container instances. + Imds, + /// App Service and Functions: `IDENTITY_ENDPOINT`, with + /// `IDENTITY_HEADER` in `X-IDENTITY-HEADER`. + AppService { + endpoint: String, + header: Zeroizing, + }, +} + +/// The token of the managed identity of the Azure resource this process +/// runs on. Native only. Wrap it in a [`CachedToken`](super::CachedToken). +/// +/// With a client id it asks for that user-assigned identity; without one, +/// for the system-assigned identity. +pub struct ManagedIdentityLogin { + source: Source, + client_id: Option, + transport: T, + clock: Clock, +} + +impl fmt::Debug for ManagedIdentityLogin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let source = match &self.source { + Source::Imds => "imds".to_owned(), + Source::AppService { endpoint, .. } => format!("app service at {endpoint}"), + }; + f.debug_struct("ManagedIdentityLogin") + .field("source", &source) + .field("client_id", &self.client_id) + .finish_non_exhaustive() + } +} + +/// An App Service identity endpoint: HTTP to a loopback host (App Service +/// serves it on `127.0.0.1`), or HTTPS, with no query, fragment or +/// credentials. The identity header is sent there, so it must not be +/// anywhere else. +fn is_identity_endpoint(endpoint: &str) -> bool { + let Ok(parsed) = url::Url::parse(endpoint) else { + return false; + }; + let loopback = match parsed.host() { + Some(url::Host::Domain(domain)) => domain == "localhost", + Some(url::Host::Ipv4(ip)) => ip.is_loopback(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + None => return false, + }; + (parsed.scheme() == "https" || (parsed.scheme() == "http" && loopback)) + && parsed.query().is_none() + && parsed.fragment().is_none() + && parsed.username().is_empty() + && parsed.password().is_none() +} + +impl ManagedIdentityLogin { + /// The system-assigned identity, from IMDS, through `transport`. + pub fn imds(transport: T) -> Self { + Self { + source: Source::Imds, + client_id: None, + transport, + clock: system_clock(), + } + } + + /// The identity of an App Service or Functions app, from `endpoint` + /// (`IDENTITY_ENDPOINT`) with `header` (`IDENTITY_HEADER`). + pub fn app_service( + endpoint: impl Into, + header: impl Into, + transport: T, + ) -> Result { + let endpoint = endpoint.into(); + if !is_identity_endpoint(&endpoint) { + return Err(AuthError::access_denied(format!( + "{endpoint:?} is not an App Service identity endpoint (HTTP to a loopback \ + host, or HTTPS, with no query)" + ))); + } + let header = Zeroizing::new(header.into()); + if header.is_empty() || header.contains(['\r', '\n']) { + return Err(AuthError::access_denied( + "IDENTITY_HEADER is empty or holds a line break", + )); + } + Ok(Self { + source: Source::AppService { endpoint, header }, + client_id: None, + transport, + clock: system_clock(), + }) + } + + /// App Service when `IDENTITY_ENDPOINT` and `IDENTITY_HEADER` are set, + /// else IMDS. `AZURE_CLIENT_ID`, if set, names a user-assigned identity. + pub fn from_env_lookup(transport: T, env: &EnvLookup) -> Result { + let login = match (env("IDENTITY_ENDPOINT"), env("IDENTITY_HEADER")) { + (Some(endpoint), Some(header)) => Self::app_service(endpoint, header, transport)?, + _ => Self::imds(transport), + }; + Ok(match env("AZURE_CLIENT_ID") { + Some(client_id) => login.with_client_id(client_id), + None => login, + }) + } + + /// Ask for the user-assigned identity with `client_id`. + pub fn with_client_id(mut self, client_id: impl Into) -> Self { + self.client_id = Some(client_id.into()); + self + } + + /// Read the time from `clock` (App Service answers with `expires_on`). + pub fn with_clock(mut self, clock: Clock) -> Self { + self.clock = clock; + self + } + + /// The token URL for `scope`. + pub fn url(&self, scope: &str) -> String { + let (base, api_version) = match &self.source { + Source::Imds => (IMDS_TOKEN_URL, IMDS_API_VERSION), + Source::AppService { endpoint, .. } => (endpoint.as_str(), APP_SERVICE_API_VERSION), + }; + let mut query = url::form_urlencoded::Serializer::new(String::new()); + let _ = query + .append_pair("api-version", api_version) + .append_pair("resource", resource_of(scope)); + if let Some(client_id) = &self.client_id { + let _ = query.append_pair("client_id", client_id); + } + format!("{base}?{}", query.finish()) + } + + /// The transport. + pub fn transport(&self) -> &T { + &self.transport + } +} + +impl TokenProvider for ManagedIdentityLogin { + async fn token(&self, scope: &str) -> Result { + let request = VendorRequest::new("GET", self.url(scope)); + let (request, source) = match &self.source { + Source::Imds => (request.header("metadata", "true"), "IMDS"), + Source::AppService { header, .. } => ( + request.header("x-identity-header", header.as_str()), + "the App Service identity endpoint", + ), + }; + let response = self.transport.send(request).await.map_err(|error| { + AuthError::unavailable(format!("{source} got no response (not on Azure?): {error}")) + })?; + read_token_response(source, &response, (self.clock)()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_endpoints_are_checked() { + for good in [ + "http://127.0.0.1:41056/msi/token", + "http://localhost:8081/msi/token/", + "http://[::1]:80/token", + "https://identity.example/token", + ] { + assert!(is_identity_endpoint(good), "{good}"); + } + for bad in [ + "http://169.254.169.254/token", + "http://evil.example/token", + "http://127.0.0.1/token?x=1", + "http://user@127.0.0.1/token", + "file:///etc/passwd", + "not a url", + ] { + assert!(!is_identity_endpoint(bad), "{bad}"); + } + } + + #[test] + fn urls_name_the_resource_and_the_identity() { + let login = ManagedIdentityLogin::imds(()); + assert_eq!( + login.url("https://vault.azure.net/.default"), + "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01\ + &resource=https%3A%2F%2Fvault.azure.net" + ); + let login = ManagedIdentityLogin::app_service("http://127.0.0.1:41056/msi/token", "h", ()) + .unwrap() + .with_client_id("c-1"); + assert_eq!( + login.url("https://managedhsm.azure.net/.default"), + "http://127.0.0.1:41056/msi/token?api-version=2019-08-01\ + &resource=https%3A%2F%2Fmanagedhsm.azure.net&client_id=c-1" + ); + assert!(!format!("{login:?}").contains("\"h\"")); + assert!(ManagedIdentityLogin::app_service("http://127.0.0.1/t", "a\nb", ()).is_err()); + } +} diff --git a/packages/stack-kms-azure/src/auth/mod.rs b/packages/stack-kms-azure/src/auth/mod.rs new file mode 100644 index 0000000000..a1ac29988e --- /dev/null +++ b/packages/stack-kms-azure/src/auth/mod.rs @@ -0,0 +1,423 @@ +//! Microsoft Entra ID (Azure AD) access tokens for Key Vault and Managed +//! HSM, and the credentials that get them. +//! +//! [`TokenProvider`] is the seam: the client asks it for an [`AccessToken`] +//! for a scope (`https://vault.azure.net/.default` or +//! `https://managedhsm.azure.net/.default`) before each request. This crate +//! provides: +//! +//! * [`AccessToken`] itself: a static token, for example from +//! `az account get-access-token --resource https://vault.azure.net`. +//! Everywhere. It is used for every scope until Azure refuses it. +//! * [`ClientSecretLogin`]: a service principal's client secret, exchanged +//! at the Entra ID token endpoint (OAuth 2.0 client credentials). +//! Everywhere. +//! * [`WorkloadIdentityLogin`]: a federated token from a file (AKS +//! workload identity, `AZURE_FEDERATED_TOKEN_FILE`), exchanged as a +//! client assertion. **Native only.** +//! * [`ManagedIdentityLogin`]: the managed identity of an Azure VM, VM scale +//! set, AKS node or container instance (IMDS), or of an App Service or +//! Functions app (`IDENTITY_ENDPOINT`). **Native only.** +//! * [`CachedToken`]: keeps the token of each scope and gets a new one +//! before it expires. +//! * [`DefaultCredentials`] (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. +//! +//! **Not supported:** client certificate credentials +//! (`AZURE_CLIENT_CERTIFICATE_PATH`). They sign a JWT with the service +//! principal's RSA key, and the only pure-Rust RSA crate has an open timing +//! advisory (RUSTSEC-2023-0071). Use a client secret, workload identity or a +//! managed identity. Also not supported: Azure CLI credentials, Azure Arc +//! and Service Fabric managed identity. + +use std::future::Future; +use std::time::Duration; + +use serde::Deserialize; +use stack_kms::{BackendErrorKind, MaybeSend}; +use stack_kms_vendor::{ + classify_status, CredentialError, CredentialKind, FetchCredential, VendorResponse, +}; +use web_time::SystemTime; + +#[cfg(not(target_arch = "wasm32"))] +use crate::config::EnvLookup; +use crate::time::unix_seconds; + +mod client_secret; +#[cfg(not(target_arch = "wasm32"))] +mod managed_identity; +#[cfg(not(target_arch = "wasm32"))] +mod workload_identity; + +pub use client_secret::{ + is_tenant_id, ClientSecretLogin, DEFAULT_AUTHORITY_HOST, JWT_BEARER_ASSERTION_TYPE, +}; +#[cfg(not(target_arch = "wasm32"))] +pub use managed_identity::{ + ManagedIdentityLogin, APP_SERVICE_API_VERSION, IMDS_API_VERSION, IMDS_TOKEN_URL, +}; +#[cfg(not(target_arch = "wasm32"))] +pub use workload_identity::WorkloadIdentityLogin; + +/// The environment variable of a static access token. Azure has no +/// standard name for one; this one is ours. +pub const ACCESS_TOKEN_VARIABLE: &str = "AZURE_KEY_VAULT_ACCESS_TOKEN"; + +pub use stack_kms_vendor::{AccessToken, DEFAULT_REFRESH_BEFORE_EXPIRY}; + +/// The credential kind of this crate: an Entra ID [`AccessToken`], one for +/// each scope. It names [`AuthError`] and [`CachedToken`]. +#[derive(Debug)] +pub enum AzureCredentials {} + +impl CredentialKind for AzureCredentials { + const LABEL: &'static str = "Azure credentials"; + type Key = String; + type Credential = AccessToken; +} + +/// A token could not be got. The message starts with "Azure credentials". +pub type AuthError = CredentialError; + +/// Where a client gets its access tokens. 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 { + /// A token for `scope` (`https://vault.azure.net/.default`). A managed + /// identity asks for the resource, the scope without `/.default`. + fn token( + &self, + scope: &str, + ) -> impl Future> + MaybeSend; + + /// Azure refused a token (HTTP 401). A caching provider drops its + /// tokens, so that the next request gets a new one. The default does + /// nothing. + fn invalidate(&self) {} +} + +impl TokenProvider for AccessToken { + fn token( + &self, + _scope: &str, + ) -> impl Future> + MaybeSend { + std::future::ready(Ok(self.clone())) + } +} + +/// The resource of a scope: the scope without `/.default`. +pub fn resource_of(scope: &str) -> &str { + scope.strip_suffix("/.default").unwrap_or(scope) +} + +/// An integer of a JSON response that may be a number or a string (IMDS and +/// App Service send strings). +fn read_u64(value: Option<&serde_json::Value>) -> Option { + match value? { + serde_json::Value::String(text) => text.trim().parse().ok(), + serde_json::Value::Number(number) => number.as_u64(), + _ => None, + } +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: Option, + expires_in: Option, + expires_on: Option, +} + +#[derive(Deserialize)] +struct TokenErrorBody { + error: Option, + error_description: Option, +} + +/// The access token of a token endpoint or managed identity response, read +/// at `now`. `source` names it in errors. +/// +/// The life is `expires_in` (seconds, a number or a string), else +/// `expires_on` (Unix seconds) less `now`. +pub(crate) fn read_token_response( + source: &str, + response: &VendorResponse, + now: SystemTime, +) -> Result { + if let Some(kind) = classify_status(response.status) { + // A refused grant is HTTP 400 or 401 (`invalid_client`); IMDS + // answers 400 or 404 for an identity it does not have. None of these + // is a missing key or a damaged ciphertext. IMDS answers 410 while + // it updates, which a later try fixes. + let kind = match (response.status, kind) { + (410, _) => BackendErrorKind::Unavailable, + (_, BackendErrorKind::InvalidCiphertext | BackendErrorKind::NotFound) => { + BackendErrorKind::AccessDenied + } + (_, kind) => kind, + }; + let detail = serde_json::from_slice::(&response.body) + .ok() + .map(|body| { + let error = match body.error { + Some(serde_json::Value::String(text)) => text, + Some(serde_json::Value::Object(object)) => object + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or_default() + .to_owned(), + _ => String::new(), + }; + match body.error_description { + Some(description) => format!("{error}: {description}"), + None => error, + } + }) + .unwrap_or_default(); + return Err(AuthError::new( + kind, + format!("{source} failed with HTTP {}: {detail}", response.status), + )); + } + let parsed: TokenResponse = serde_json::from_slice(&response.body).map_err(|_| { + AuthError::unavailable(format!("{source} returned a response that is not JSON")) + })?; + let token = AccessToken::new( + parsed + .access_token + .filter(|token| !token.is_empty()) + .ok_or_else(|| AuthError::unavailable(format!("{source} returned no access token")))?, + ); + let life = match read_u64(parsed.expires_in.as_ref()) { + Some(seconds) => Some(seconds), + None => { + read_u64(parsed.expires_on.as_ref()).map(|end| end.saturating_sub(unix_seconds(now))) + } + }; + Ok(match life { + None | Some(0) => token, + Some(seconds) => token.with_expires_in(Duration::from_secs(seconds)), + }) +} + +/// Keeps the token `inner` returns for each scope, and gets a new one when +/// it is near its expiry: [`DEFAULT_REFRESH_BEFORE_EXPIRY`] before, or a +/// third of its life before when it lives less than three times that. +/// +/// One fetch runs at a time: callers that arrive during a fetch wait for it. +/// If a fetch fails while the cached token has not expired, the cached +/// token is still returned. A token with no expiry is kept until Azure +/// refuses it ([`TokenProvider::invalidate`], which drops every scope's). +pub type CachedToken

= stack_kms_vendor::CachedToken; + +impl FetchCredential

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

{ + fn token( + &self, + scope: &str, + ) -> impl Future> + MaybeSend { + self.get(scope) + } + + fn invalidate(&self) { + stack_kms_vendor::CachedToken::invalidate(self); + } +} + +#[cfg(not(target_arch = "wasm32"))] +mod default { + use stack_kms_vendor::VendorTransport; + + use super::*; + use crate::config::process_env; + + /// The credentials [`DefaultCredentials::from_env`] picks. + #[derive(Debug)] + pub enum DefaultCredentials { + /// [`ACCESS_TOKEN_VARIABLE`]. + Token(AccessToken), + /// `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and `AZURE_CLIENT_SECRET`. + ClientSecret(CachedToken>), + /// `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and + /// `AZURE_FEDERATED_TOKEN_FILE`. + WorkloadIdentity(CachedToken>), + /// A managed identity: App Service (`IDENTITY_ENDPOINT`) or IMDS. + ManagedIdentity(CachedToken>), + } + + impl DefaultCredentials { + /// Pick credentials from the environment, in this order (the order + /// of the Azure SDKs' `DefaultAzureCredential`, less the ones this + /// crate does not support): + /// + /// 1. [`ACCESS_TOKEN_VARIABLE`]: a static access token. + /// 2. `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and `AZURE_CLIENT_SECRET`: + /// a client secret. `AZURE_CLIENT_CERTIFICATE_PATH` with no + /// secret is refused (not supported). + /// 3. `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and + /// `AZURE_FEDERATED_TOKEN_FILE`: workload identity. + /// 4. Otherwise a managed identity: App Service when + /// `IDENTITY_ENDPOINT` and `IDENTITY_HEADER` are set, else IMDS. + /// `AZURE_CLIENT_ID`, if set, names a user-assigned identity. + /// + /// `AZURE_AUTHORITY_HOST` changes the Entra ID host (for a national + /// cloud). The logins send through `transport` and are cached. + pub fn from_env(transport: T) -> Result { + Self::from_env_lookup(transport, &process_env()) + } + + /// As [`from_env`](Self::from_env), reading the variables with `env`. + pub fn from_env_lookup(transport: T, env: &EnvLookup) -> Result { + if let Some(token) = env(ACCESS_TOKEN_VARIABLE).map(AccessToken::new) { + return Ok(Self::Token(token)); + } + let tenant = env("AZURE_TENANT_ID"); + let client = env("AZURE_CLIENT_ID"); + if let (Some(tenant), Some(client)) = (&tenant, &client) { + if let Some(secret) = env("AZURE_CLIENT_SECRET") { + let login = ClientSecretLogin::new(tenant, client, secret, transport)?; + let login = match env("AZURE_AUTHORITY_HOST") { + Some(host) => login.with_authority_host(host)?, + None => login, + }; + return Ok(Self::ClientSecret(CachedToken::new(login))); + } + if env("AZURE_CLIENT_CERTIFICATE_PATH").is_some() { + return Err(AuthError::access_denied( + "client certificate credentials (AZURE_CLIENT_CERTIFICATE_PATH) are \ + not supported; use a client secret, workload identity or a managed \ + identity", + )); + } + if let Some(file) = env("AZURE_FEDERATED_TOKEN_FILE") { + let login = WorkloadIdentityLogin::new(tenant, client, file, transport)?; + let login = match env("AZURE_AUTHORITY_HOST") { + Some(host) => login.with_authority_host(host)?, + None => login, + }; + return Ok(Self::WorkloadIdentity(CachedToken::new(login))); + } + } + Ok(Self::ManagedIdentity(CachedToken::new( + ManagedIdentityLogin::from_env_lookup(transport, env)?, + ))) + } + } + + impl TokenProvider for DefaultCredentials { + async fn token(&self, scope: &str) -> Result { + match self { + Self::Token(token) => Ok(token.clone()), + Self::ClientSecret(login) => login.token(scope).await, + Self::WorkloadIdentity(login) => login.token(scope).await, + Self::ManagedIdentity(login) => login.token(scope).await, + } + } + + fn invalidate(&self) { + match self { + Self::Token(_) => {} + Self::ClientSecret(login) => login.invalidate(), + Self::WorkloadIdentity(login) => login.invalidate(), + Self::ManagedIdentity(login) => login.invalidate(), + } + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub use default::DefaultCredentials; + +#[cfg(test)] +mod tests { + use web_time::UNIX_EPOCH; + + use super::*; + + #[test] + fn the_resource_is_the_scope_without_default() { + assert_eq!( + resource_of("https://vault.azure.net/.default"), + "https://vault.azure.net" + ); + assert_eq!(resource_of("https://x"), "https://x"); + } + + #[test] + fn token_responses_are_read_and_failures_classified() { + let now = UNIX_EPOCH + Duration::from_secs(1_000_000); + for (body, life) in [ + ( + &br#"{"token_type":"Bearer","expires_in":3599,"access_token":"t"}"#[..], + Some(3599), + ), + (br#"{"access_token":"t","expires_in":"86399"}"#, Some(86399)), + ( + br#"{"access_token":"t","expires_on":"1003600"}"#, + Some(3600), + ), + (br#"{"access_token":"t","expires_on":1000060}"#, Some(60)), + (br#"{"access_token":"t","expires_on":"999999"}"#, None), + (br#"{"access_token":"t"}"#, None), + ] { + let token = + read_token_response("x", &VendorResponse::new(200, body.to_vec()), now).unwrap(); + assert_eq!(token.token(), "t"); + assert_eq!(token.expires_in(), life.map(Duration::from_secs)); + } + for (status, body, kind) in [ + ( + 400, + &br#"{"error":"invalid_client","error_description":"AADSTS7000215: Invalid client secret provided."}"#[..], + BackendErrorKind::AccessDenied, + ), + (401, b"{}", BackendErrorKind::AccessDenied), + (404, b"not found", BackendErrorKind::AccessDenied), + (410, b"", BackendErrorKind::Unavailable), + (429, b"", BackendErrorKind::Throttled), + (503, b"", BackendErrorKind::Unavailable), + (200, b"not json", BackendErrorKind::Unavailable), + ( + 200, + br#"{"access_token":""}"#, + BackendErrorKind::Unavailable, + ), + ] { + let error = + read_token_response("x", &VendorResponse::new(status, body.to_vec()), now) + .unwrap_err(); + assert_eq!(error.kind(), kind, "{status}"); + } + let error = read_token_response( + "the token endpoint", + &VendorResponse::new( + 401, + br#"{"error":"invalid_client","error_description":"AADSTS7000215: Invalid client secret provided."}"# + .to_vec(), + ), + now, + ) + .unwrap_err(); + assert_eq!( + error.to_string(), + "Azure credentials: the token endpoint failed with HTTP 401: \ + invalid_client: AADSTS7000215: Invalid client secret provided." + ); + } +} diff --git a/packages/stack-kms-azure/src/auth/workload_identity.rs b/packages/stack-kms-azure/src/auth/workload_identity.rs new file mode 100644 index 0000000000..b4d0ce7efa --- /dev/null +++ b/packages/stack-kms-azure/src/auth/workload_identity.rs @@ -0,0 +1,105 @@ +//! Workload identity federation: a federated token from a file, exchanged +//! at the Entra ID token endpoint. Native only. + +use std::fmt; +use std::path::PathBuf; + +use stack_kms_vendor::VendorTransport; +use zeroize::Zeroizing; + +use super::client_secret::{EntraApp, JWT_BEARER_ASSERTION_TYPE}; +use super::{AccessToken, AuthError, TokenProvider}; +use crate::time::{system_clock, Clock}; + +/// A federated token (for example the service account token that AKS +/// workload identity projects at `AZURE_FEDERATED_TOKEN_FILE`), exchanged at +/// the Entra ID token endpoint as a client assertion. The file is read on +/// each login, because the platform rotates it. Wrap it in a +/// [`CachedToken`](super::CachedToken). Native only. +pub struct WorkloadIdentityLogin { + app: EntraApp, + token_file: PathBuf, + transport: T, + clock: Clock, +} + +impl fmt::Debug for WorkloadIdentityLogin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WorkloadIdentityLogin") + .field("tenant_id", &self.app.tenant_id()) + .field("client_id", &self.app.client_id()) + .field("token_file", &self.token_file) + .finish_non_exhaustive() + } +} + +impl WorkloadIdentityLogin { + /// Log in as `client_id` of `tenant_id` with the federated token in + /// `token_file`, through `transport`. + pub fn new( + tenant_id: &str, + client_id: &str, + token_file: impl Into, + transport: T, + ) -> Result { + Ok(Self { + app: EntraApp::new(tenant_id, client_id)?, + token_file: token_file.into(), + transport, + clock: system_clock(), + }) + } + + /// Post to the Entra ID host `host` instead of + /// [`DEFAULT_AUTHORITY_HOST`](super::DEFAULT_AUTHORITY_HOST). + pub fn with_authority_host(mut self, host: impl Into) -> Result { + self.app = self.app.with_authority_host(host.into())?; + Ok(self) + } + + /// Read the time from `clock`. + pub fn with_clock(mut self, clock: Clock) -> Self { + self.clock = clock; + self + } + + /// The token endpoint URL. + pub fn token_url(&self) -> String { + self.app.token_url() + } + + /// The transport. + pub fn transport(&self) -> &T { + &self.transport + } +} + +impl TokenProvider for WorkloadIdentityLogin { + async fn token(&self, scope: &str) -> Result { + let assertion = + Zeroizing::new(std::fs::read_to_string(&self.token_file).map_err(|_| { + AuthError::access_denied(format!( + "cannot read the federated token file {}", + self.token_file.display() + )) + })?); + let assertion = assertion.trim(); + if assertion.is_empty() { + return Err(AuthError::access_denied( + "the federated token file is empty", + )); + } + self.app + .post( + &self.transport, + &self.clock, + "the workload identity login", + scope, + &[ + ("client_assertion_type", JWT_BEARER_ASSERTION_TYPE), + ("client_assertion", assertion), + ], + ) + .await + } +} diff --git a/packages/stack-kms-azure/src/client.rs b/packages/stack-kms-azure/src/client.rs new file mode 100644 index 0000000000..d1dd991f9a --- /dev/null +++ b/packages/stack-kms-azure/src/client.rs @@ -0,0 +1,814 @@ +//! The Key Vault client and its [`KeyVendorBackend`] implementation. + +use std::collections::HashMap; +use std::sync::{Mutex, PoisonError}; +use std::time::Duration; + +use base64ct::{Base64UrlUnpadded, 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, BOUND_KEY_LEN}; +use stack_kms_vendor::{ + classify_status, KeyBytes, KeyVendorBackend, TransportError, VendorRequest, VendorResponse, + VendorTransport, +}; +use zeroize::Zeroizing; + +use crate::auth::{AuthError, TokenProvider}; +use crate::config::{is_version, AzureKeyVaultConfig, KeyId, WrapAlgorithm, MAX_VERSION_LEN}; + +/// This client's vendor: what [`KeyVendorBackend::vendor`] answers and what its errors name. +const VENDOR: KeyVendor = KeyVendor::Azure; + +/// The first byte of a wrapped key this client stores: the format of the +/// rest (`algorithm(1) ‖ version length(1) ‖ version ‖ Azure ciphertext`). +pub const WRAPPED_KEY_FORMAT: u8 = 1; + +/// The length of the plaintext the client wraps: the bound key +/// ([`BOUND_KEY_LEN`], 65 bytes) and 7 zero bytes. A256KW needs a multiple +/// of 8 bytes; RSA-OAEP-256 with a 2048-bit key takes up to 190. +pub const WRAP_PLAINTEXT_LEN: usize = 72; + +/// The longest `Retry-After` the client waits for before it reports +/// `Throttled`. The adapter then backs off on its own as well. +pub const MAX_RETRY_AFTER: Duration = Duration::from_secs(30); + +/// A failure of a Key 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 AzureKeyVaultError { + /// Key Vault answered with an error. + #[error("Azure Key Vault {operation} failed with HTTP {status}: {code}: {message}")] + Service { + /// The operation (`unwrapkey`). + operation: &'static str, + /// The HTTP status. + status: u16, + /// The error code (`Forbidden`), with the inner code after a `/` + /// when there is one (`Forbidden/KeyDisabled`), or `unknown`. + code: String, + /// Azure's error message. + message: String, + }, + /// The request got no response. + #[error("Azure Key Vault {operation} got no response: {source}")] + Transport { + /// The operation. + operation: &'static str, + /// The transport failure. + source: TransportError, + }, + /// No access token could be got. + #[error(transparent)] + Auth(#[from] AuthError), + /// Key Vault answered with a success that this client cannot read or + /// does not trust. + #[error("Azure Key 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 Key Vault. + #[error("{0}")] + Refused(&'static str), +} + +fn backend(kind: BackendErrorKind, error: AzureKeyVaultError) -> Error { + Error::Backend { + backend: VENDOR.into(), + kind, + source: Box::new(error), + } +} + +fn invalid(operation: &'static str, reason: &'static str) -> Error { + backend( + BackendErrorKind::Unavailable, + AzureKeyVaultError::InvalidResponse { operation, reason }, + ) +} + +fn refused(kind: BackendErrorKind, reason: &'static str) -> Error { + backend(kind, AzureKeyVaultError::Refused(reason)) +} + +/// The error codes (`error.code`, or `error.innererror.code`) that mean a +/// key is disabled, expired or not yet valid. They map to `Unavailable`, as +/// AWS KMS's `DisabledException` does: the key exists, and an operator can +/// enable it again. +pub const KEY_STATE_CODES: [&str; 3] = ["KeyDisabled", "KeyExpired", "KeyNotYetValid"]; + +/// The failure kind of a Key Vault error response, by the HTTP `status` +/// and the error codes `codes` (`error.code` and `error.innererror.code`). +/// `unwrap` is true for an `unwrapkey` call. +/// +/// * The codes of [`KEY_STATE_CODES`]: `Unavailable`. +/// * 429, or `Throttled`: `Throttled` (the client first waits for +/// `Retry-After`, up to [`MAX_RETRY_AFTER`]). +/// * 401, 403, `Unauthorized`, `Forbidden`: `AccessDenied` (a 401 also +/// drops the cached token). +/// * 404, `KeyNotFound`, `NotFound`: `NotFound`. +/// * 5xx: `Unavailable`. +/// * 400 `BadParameter` on an `unwrapkey`: `InvalidCiphertext` (Azure +/// could not decrypt the bytes: damaged, or wrapped by another key or +/// version). +/// * Any other 4xx is a request Key Vault refused (`AccessDenied`), as for +/// AWS KMS, Vault and Cloud KMS. +pub fn error_kind(status: u16, codes: &[&str], unwrap: bool) -> BackendErrorKind { + let has = |wanted: &[&str]| codes.iter().any(|code| wanted.contains(code)); + if has(&KEY_STATE_CODES) { + return BackendErrorKind::Unavailable; + } + if status == 429 || has(&["Throttled"]) { + return BackendErrorKind::Throttled; + } + if matches!(status, 401 | 403) || has(&["Unauthorized", "Forbidden"]) { + return BackendErrorKind::AccessDenied; + } + if status == 404 || has(&["KeyNotFound", "NotFound"]) { + return BackendErrorKind::NotFound; + } + if unwrap && status == 400 && has(&["BadParameter"]) { + return BackendErrorKind::InvalidCiphertext; + } + match classify_status(status) { + Some(BackendErrorKind::InvalidCiphertext) | None => BackendErrorKind::AccessDenied, + Some(kind) => kind, + } +} + +#[derive(Deserialize)] +struct ErrorBody { + error: Option, +} + +#[derive(Deserialize)] +struct ErrorDetail { + code: Option, + message: Option, + innererror: Option, +} + +#[derive(Deserialize)] +struct InnerError { + code: Option, +} + +fn service_error(operation: &'static str, response: &VendorResponse) -> Error { + let detail = serde_json::from_slice::(&response.body) + .ok() + .and_then(|body| body.error); + let (code, inner, message) = match detail { + Some(detail) => ( + detail.code, + detail.innererror.and_then(|inner| inner.code), + detail.message.unwrap_or_default(), + ), + None => (None, None, String::new()), + }; + let codes: Vec<&str> = [code.as_deref(), inner.as_deref()] + .into_iter() + .flatten() + .collect(); + let kind = error_kind(response.status, &codes, operation == "unwrapkey"); + let code = match codes.as_slice() { + [] => "unknown".to_owned(), + codes => codes.join("/"), + }; + backend( + kind, + AzureKeyVaultError::Service { + operation, + status: response.status, + code, + message, + }, + ) +} + +/// The seconds of a `Retry-After` header, capped at [`MAX_RETRY_AFTER`]. +/// `None` when there is none or it is not a number of seconds. +pub fn retry_after(response: &VendorResponse) -> Option { + let seconds: u64 = response.header_value("retry-after")?.trim().parse().ok()?; + Some(Duration::from_secs(seconds).min(MAX_RETRY_AFTER)) +} + +/// Decode base64url, with or without padding (Azure sends none). +fn decode_base64url(text: &str) -> Option> { + Base64UrlUnpadded::decode_vec(text.trim_end_matches('=')).ok() +} + +fn serialize_secret( + value: &Zeroizing, + serializer: S, +) -> Result { + serializer.serialize_str(value) +} + +#[derive(Serialize)] +struct KeyOperationRequest { + alg: &'static str, + #[serde(serialize_with = "serialize_secret")] + value: Zeroizing, +} + +#[derive(Deserialize)] +struct KeyOperationResponse { + kid: Option, + value: Option, +} + +#[derive(Deserialize)] +struct KeyBundle { + key: Option, +} + +#[derive(Deserialize)] +struct JsonWebKey { + kid: Option, + kty: Option, +} + +/// The stored form of an Azure wrapped key: [`WRAPPED_KEY_FORMAT`], the +/// algorithm byte ([`WrapAlgorithm::byte`]), the length of the key version +/// (1 byte), the key version (ASCII), and the Azure ciphertext. +/// +/// A version is at most [`MAX_VERSION_LEN`] bytes ([`is_version`]); a +/// longer one is cut, and the result then does not split. +pub fn compact_ciphertext(algorithm: WrapAlgorithm, version: &str, ciphertext: &[u8]) -> Vec { + let version = &version.as_bytes()[..version.len().min(MAX_VERSION_LEN)]; + let mut out = Vec::with_capacity(3 + version.len() + ciphertext.len()); + out.push(WRAPPED_KEY_FORMAT); + out.push(algorithm.byte()); + out.push(version.len() as u8); + out.extend_from_slice(version); + out.extend_from_slice(ciphertext); + out +} + +/// A stored wrapped key, split into its parts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WrappedKeyParts<'a> { + /// The algorithm that wrapped it. + pub algorithm: WrapAlgorithm, + /// The key version that wrapped it. + pub version: &'a str, + /// The Azure ciphertext. + pub ciphertext: &'a [u8], +} + +/// The parts of a stored wrapped key. `None` if the bytes are not one. +pub fn split_wrapped_key(wrapped: &[u8]) -> Option> { + let (&format, rest) = wrapped.split_first()?; + if format != WRAPPED_KEY_FORMAT { + return None; + } + let (&algorithm, rest) = rest.split_first()?; + let algorithm = WrapAlgorithm::from_byte(algorithm)?; + let (&len, rest) = rest.split_first()?; + let (version, ciphertext) = rest.split_at_checked(usize::from(len))?; + let version = std::str::from_utf8(version).ok()?; + if !is_version(version) || ciphertext.is_empty() { + return None; + } + Some(WrappedKeyParts { + algorithm, + version, + ciphertext, + }) +} + +/// The key version that wrapped `wrapped`, or `None` if the bytes are not a +/// wrapped key of this client. Use it to find which versions stored keys +/// still need before you disable a version. +pub fn wrapped_key_version(wrapped: &[u8]) -> Option<&str> { + split_wrapped_key(wrapped).map(|parts| parts.version) +} + +/// The plaintext the client wraps: the bound key of `key` under `aad`, +/// padded to [`WRAP_PLAINTEXT_LEN`] with zeros. +pub fn wrap_plaintext(key: &KeyBytes, aad: &[u8]) -> Zeroizing> { + let mut plaintext = bind_key(key, aad); + plaintext.resize(WRAP_PLAINTEXT_LEN, 0); + plaintext +} + +/// The key in an unwrapped plaintext, if it is one this client made under +/// `aad`. Anything else is `InvalidCiphertext`. +fn open_plaintext(plaintext: &[u8], aad: &[u8]) -> Result { + if plaintext.len() != WRAP_PLAINTEXT_LEN + || plaintext[BOUND_KEY_LEN..].iter().any(|&byte| byte != 0) + { + return Err(refused( + BackendErrorKind::InvalidCiphertext, + "the unwrapped key is not a bound key of this client", + )); + } + open_bound_key(&plaintext[..BOUND_KEY_LEN], aad).map_err(|_| { + refused( + BackendErrorKind::InvalidCiphertext, + "the wrapped key belongs to other associated data", + ) + }) +} + +/// The version and the algorithm a wrap uses for one key reference. +#[derive(Clone, Debug)] +struct Resolved { + version: String, + algorithm: WrapAlgorithm, +} + +/// An Azure Key Vault or Managed HSM client, and the Azure key vendor +/// backend ([`KeyVendor::Azure`]). +/// +/// It speaks the Key Vault REST API ([`API_VERSION`](crate::API_VERSION): +/// `GET /keys/{name}`, `wrapkey` and `unwrapkey`) with an Entra ID access +/// token, over a [`VendorTransport`], so the same code runs on native Rust +/// and in the WASI guest. +/// +/// * `key_ref` (from the keyset record) is the key identifier, +/// `https://{vault}.vault.azure.net/keys/{name}` or +/// `https://{hsm}.managedhsm.azure.net/keys/{name}`, optionally with +/// `/{version}`. A Key Vault `RSA` or `RSA-HSM` key wraps with +/// RSA-OAEP-256; a Managed HSM `oct-HSM` key wraps with A256KW. +/// * **Versions.** Azure needs the key version to unwrap, so every stored +/// wrapped key names the version that wrapped it +/// ([`wrapped_key_version`]) and its algorithm. An unwrap reads no key. +/// With no version in `key_ref`, the client reads the key once +/// (`GET /keys/{name}`) to learn its current version and type, and wraps +/// under that version for the life of the client. After a failed wrap it +/// reads the key again, so a disabled old version is left behind. With a +/// version, wraps use that version. +/// * **Associated data.** Key Vault's `wrapkey` takes no associated data. +/// The client wraps a bound key ([`stack_kms_vendor::bound`]): a SHA-256 +/// digest of the whole associated data (the keyset id, the vendor and the +/// key mode) beside the key, which an unwrap checks. This is sound +/// because RSA-OAEP and AES key wrap are non-malleable. It is the Azure +/// trade-off: Azure does not see or log the binding, so a wrapped key +/// moved to another keyset record unwraps at Azure (and is then refused +/// here as `InvalidCiphertext`). +/// * **Calls.** The client wraps only a keyset's root key. One `wrapkey` +/// creates a keyset record or rewraps it, and one `unwrapkey` unwraps the +/// root key for each root key TTL +/// ([`DEFAULT_ROOT_KEY_TTL`](stack_kms_vendor::DEFAULT_ROOT_KEY_TTL)). +/// * Errors map to [`BackendErrorKind`] by [`error_kind`]. A 401 also tells +/// the token provider to drop its tokens ([`TokenProvider::invalidate`]). +pub struct AzureKeyVault { + config: AzureKeyVaultConfig, + transport: T, + auth: A, + resolved: Mutex>, +} + +impl std::fmt::Debug for AzureKeyVault { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AzureKeyVault") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +/// The key identifier of `key_ref`. +fn key_id(key_ref: &str) -> Result { + KeyId::parse(key_ref).ok_or_else(|| { + refused( + BackendErrorKind::NotFound, + "the key reference is not an Azure Key Vault or Managed HSM key identifier", + ) + }) +} + +fn to_json(operation: &'static str, value: &S) -> Result>, Error> { + serde_json::to_vec(value) + .map(Zeroizing::new) + .map_err(|_| invalid(operation, "unserializable request")) +} + +impl AzureKeyVault { + /// A client for `config`, sending through `transport`, with the access + /// tokens of `auth`. + pub fn new(config: AzureKeyVaultConfig, transport: T, auth: A) -> Self { + Self { + config, + transport, + auth, + resolved: Mutex::new(HashMap::new()), + } + } + + /// The configuration. + pub fn config(&self) -> &AzureKeyVaultConfig { + &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 `url` with a token for `scope`, and return the + /// response of a success. + async fn request( + &self, + operation: &'static str, + method: &str, + url: String, + scope: &str, + body: Option>>, + ) -> Result { + let token = self.auth.token(scope).await.map_err(|error| { + let kind = error.kind(); + backend(kind, AzureKeyVaultError::Auth(error)) + })?; + let mut request = VendorRequest::new(method, url) + .header("authorization", format!("Bearer {}", token.token())); + 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, + AzureKeyVaultError::Transport { operation, source }, + ) + })?; + if classify_status(response.status).is_some() { + if response.status == 401 { + self.auth.invalidate(); + } + if response.status == 429 { + if let Some(wait) = retry_after(&response) { + self.transport.sleep(wait).await; + } + } + return Err(service_error(operation, &response)); + } + Ok(response) + } + + /// Send one request to the key identifier `key_ref` (with no + /// operation: the key itself), and read the JSON of a success. With + /// `operation` (`wrapkey`, `create`, `rotate`) it posts to + /// `{key}/{operation}`. Any key operation can be called this way (for + /// example `create` in a test); [`KeyVendorBackend`] uses `GET`, + /// `wrapkey` and `unwrapkey`. The body is wiped after it is sent. + pub async fn send_json( + &self, + method: &str, + key_ref: &str, + operation: Option<&str>, + body: Option>>, + ) -> Result { + let id = key_id(key_ref)?; + if operation.is_some_and(|op| !op.bytes().all(|b| b.is_ascii_lowercase())) { + return Err(refused( + BackendErrorKind::AccessDenied, + "an operation is lower-case letters only", + )); + } + let response = self + .request("request", method, id.url(operation), id.scope(), body) + .await?; + serde_json::from_slice(&response.body).map_err(|_| invalid("request", "invalid JSON")) + } + + /// The version and algorithm a wrap under `id` uses: from the key + /// identifier and the configuration when both say, else from one key + /// read, kept for the life of the client. + async fn resolve(&self, key_ref: &str, id: &KeyId) -> Result { + if let (Some(version), Some(algorithm)) = (id.version(), self.config.algorithm()) { + return Ok(Resolved { + version: version.to_owned(), + algorithm, + }); + } + if let Some(resolved) = self + .resolved + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(key_ref) + { + return Ok(resolved.clone()); + } + let response = self + .request("get key", "GET", id.url(None), id.scope(), None) + .await?; + let bundle: KeyBundle = serde_json::from_slice(&response.body) + .map_err(|_| invalid("get key", "invalid JSON"))?; + let key = bundle + .key + .ok_or_else(|| invalid("get key", "no key in the response"))?; + let version = key + .kid + .as_deref() + .and_then(KeyId::parse) + .filter(|kid| kid.vault() == id.vault() && kid.name() == id.name()) + .and_then(|kid| kid.version().map(str::to_owned)) + .ok_or_else(|| invalid("get key", "the kid is not a version of the key"))?; + if id.version().is_some_and(|pinned| pinned != version) { + return Err(invalid("get key", "the kid is not the requested version")); + } + let algorithm = key + .kty + .as_deref() + .and_then(WrapAlgorithm::for_key_type) + .ok_or_else(|| { + refused( + BackendErrorKind::AccessDenied, + "the key type cannot wrap keys (use an RSA, RSA-HSM or oct-HSM key)", + ) + })?; + if self + .config + .algorithm() + .is_some_and(|configured| configured != algorithm) + { + return Err(refused( + BackendErrorKind::AccessDenied, + "the key type does not match the configured algorithm", + )); + } + let resolved = Resolved { version, algorithm }; + let _ = self + .resolved + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(key_ref.to_owned(), resolved.clone()); + Ok(resolved) + } + + async fn wrap_resolved( + &self, + id: &KeyId, + resolved: &Resolved, + key: &KeyBytes, + aad: &[u8], + ) -> Result, Error> { + let plaintext = wrap_plaintext(key, aad); + let body = to_json( + "wrapkey", + &KeyOperationRequest { + alg: resolved.algorithm.name(), + value: Zeroizing::new(Base64UrlUnpadded::encode_string(&plaintext)), + }, + )?; + let versioned = id.with_version(&resolved.version); + let response = self + .request( + "wrapkey", + "POST", + versioned.url(Some("wrapkey")), + id.scope(), + Some(body), + ) + .await?; + let parsed: KeyOperationResponse = serde_json::from_slice(&response.body) + .map_err(|_| invalid("wrapkey", "invalid JSON"))?; + let kid = parsed.kid.as_deref().and_then(KeyId::parse); + if kid.as_ref() != Some(&versioned) { + return Err(invalid( + "wrapkey", + "the kid is not the version of the key that was asked for", + )); + } + let ciphertext = parsed + .value + .as_deref() + .and_then(decode_base64url) + .filter(|ciphertext| !ciphertext.is_empty()) + .ok_or_else(|| invalid("wrapkey", "no base64url wrapped key"))?; + Ok(compact_ciphertext( + resolved.algorithm, + &resolved.version, + &ciphertext, + )) + } +} + +impl KeyVendorBackend for AzureKeyVault { + fn vendor(&self) -> KeyVendor { + VENDOR + } + + async fn wrap(&self, key_ref: &str, key: &KeyBytes, aad: &[u8]) -> Result, Error> { + let id = key_id(key_ref)?; + let resolved = self.resolve(key_ref, &id).await?; + let result = self.wrap_resolved(&id, &resolved, key, aad).await; + if result.is_err() { + // The version may be disabled or gone: read the key again next + // time. + let _ = self + .resolved + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(key_ref); + } + result + } + + async fn unwrap(&self, key_ref: &str, wrapped: &[u8], aad: &[u8]) -> Result { + let id = key_id(key_ref)?; + let parts = split_wrapped_key(wrapped).ok_or_else(|| { + refused( + BackendErrorKind::InvalidCiphertext, + "the wrapped key is not an Azure Key Vault wrapped key", + ) + })?; + let body = to_json( + "unwrapkey", + &KeyOperationRequest { + alg: parts.algorithm.name(), + value: Zeroizing::new(Base64UrlUnpadded::encode_string(parts.ciphertext)), + }, + )?; + let versioned = id.with_version(parts.version); + let response = self + .request( + "unwrapkey", + "POST", + versioned.url(Some("unwrapkey")), + id.scope(), + Some(body), + ) + .await?; + let parsed: KeyOperationResponse = serde_json::from_slice(&response.body) + .map_err(|_| invalid("unwrapkey", "invalid JSON"))?; + let encoded = Zeroizing::new( + parsed + .value + .ok_or_else(|| invalid("unwrapkey", "no unwrapped key"))?, + ); + let plaintext = Zeroizing::new( + decode_base64url(&encoded) + .ok_or_else(|| invalid("unwrapkey", "the unwrapped key is not base64url"))?, + ); + open_plaintext(&plaintext, aad) + } + + async fn sleep(&self, duration: Duration) { + self.transport.sleep(duration).await; + } +} + +#[cfg(test)] +mod tests { + use vitaminc::protected::{Controlled, Protected}; + + use super::*; + + #[test] + fn errors_map_by_code_then_status() { + use BackendErrorKind::*; + for (status, codes, unwrap, kind) in [ + (403, &["Forbidden"][..], false, AccessDenied), + ( + 403, + &["Forbidden", "ForbiddenByPolicy"], + false, + AccessDenied, + ), + (403, &["Forbidden", "KeyDisabled"], false, Unavailable), + (400, &["BadParameter", "KeyExpired"], true, Unavailable), + (401, &["Unauthorized"], false, AccessDenied), + (401, &[], false, AccessDenied), + (404, &["KeyNotFound"], false, NotFound), + (404, &[], true, NotFound), + (429, &["Throttled"], false, Throttled), + (429, &[], false, Throttled), + (503, &["ServiceUnavailable"], false, Unavailable), + (500, &[], true, Unavailable), + (400, &["BadParameter"], true, InvalidCiphertext), + (400, &["BadParameter"], false, AccessDenied), + (400, &["BadRequest"], true, AccessDenied), + (409, &["Conflict"], false, AccessDenied), + (302, &[], false, AccessDenied), + ] { + assert_eq!( + error_kind(status, codes, unwrap), + kind, + "{status} {codes:?} {unwrap}" + ); + } + } + + #[test] + fn service_errors_read_both_codes() { + let response = VendorResponse::new( + 403, + br#"{"error":{"code":"Forbidden","message":"Operation wrapKey is not allowed on a disabled key.","innererror":{"code":"KeyDisabled"}}}"#.to_vec(), + ); + let error = service_error("wrapkey", &response); + let Error::Backend { kind, source, .. } = error else { + panic!("not a backend error"); + }; + assert_eq!(kind, BackendErrorKind::Unavailable); + assert_eq!( + source.to_string(), + "Azure Key Vault wrapkey failed with HTTP 403: Forbidden/KeyDisabled: \ + Operation wrapKey is not allowed on a disabled key." + ); + let error = service_error("unwrapkey", &VendorResponse::new(502, b"".to_vec())); + let Error::Backend { kind, source, .. } = error else { + panic!("not a backend error"); + }; + assert_eq!(kind, BackendErrorKind::Unavailable); + assert!(source.to_string().contains("HTTP 502: unknown")); + } + + #[test] + fn retry_after_is_read_and_capped() { + let response = + |value: &str| VendorResponse::new(429, Vec::new()).header("retry-after", value); + assert_eq!(retry_after(&response("3")), Some(Duration::from_secs(3))); + assert_eq!(retry_after(&response("3600")), Some(MAX_RETRY_AFTER)); + assert_eq!( + retry_after(&response("Wed, 21 Oct 2015 07:28:00 GMT")), + None + ); + assert_eq!(retry_after(&VendorResponse::new(429, Vec::new())), None); + } + + #[test] + fn wrapped_keys_split_losslessly() { + let version = "0123456789abcdef0123456789abcdef"; + let wrapped = compact_ciphertext(WrapAlgorithm::A256Kw, version, b"ciphertext"); + assert_eq!(&wrapped[..3], &[WRAPPED_KEY_FORMAT, 2, 32]); + assert_eq!( + split_wrapped_key(&wrapped), + Some(WrappedKeyParts { + algorithm: WrapAlgorithm::A256Kw, + version, + ciphertext: b"ciphertext", + }) + ); + assert_eq!(wrapped_key_version(&wrapped), Some(version)); + for bad in [ + &[][..], + &[1], + &[1, 1], + &[1, 1, 1], + &[1, 1, 1, b'a'], + &[2, 1, 1, b'a', 9], + &[1, 3, 1, b'a', 9], + &[1, 0, 1, b'a', 9], + &[1, 1, 0, 9], + &[1, 1, 1, b'-', 9], + &[1, 1, 5, b'a', 9], + &[1, 1, 1, 0xff, 9], + ] { + assert_eq!(split_wrapped_key(bad), None, "{bad:?}"); + } + } + + #[test] + fn the_plaintext_is_a_padded_bound_key() { + let key = Protected::new([5u8; 32]); + let plaintext = wrap_plaintext(&key, b"aad"); + assert_eq!(plaintext.len(), WRAP_PLAINTEXT_LEN); + assert_eq!(plaintext.len() % 8, 0); + assert_eq!( + &plaintext[..BOUND_KEY_LEN], + bind_key(&key, b"aad").as_slice() + ); + assert_eq!(&plaintext[BOUND_KEY_LEN..], &[0u8; 7]); + assert_eq!( + open_plaintext(&plaintext, b"aad").unwrap().risky_ref(), + &[5u8; 32] + ); + let kind = |result: Result| match result { + Err(Error::Backend { kind, .. }) => kind, + _ => panic!("not refused"), + }; + assert_eq!( + kind(open_plaintext(&plaintext, b"other")), + BackendErrorKind::InvalidCiphertext + ); + assert_eq!( + kind(open_plaintext(&plaintext[..BOUND_KEY_LEN], b"aad")), + BackendErrorKind::InvalidCiphertext + ); + let mut padded = plaintext.to_vec(); + padded[WRAP_PLAINTEXT_LEN - 1] = 1; + assert_eq!( + kind(open_plaintext(&padded, b"aad")), + BackendErrorKind::InvalidCiphertext + ); + } + + #[test] + fn base64url_decodes_with_or_without_padding() { + assert_eq!(decode_base64url("AQI"), Some(vec![1, 2])); + assert_eq!(decode_base64url("AQI="), Some(vec![1, 2])); + assert_eq!(decode_base64url("+/8"), None); + } +} diff --git a/packages/stack-kms-azure/src/config.rs b/packages/stack-kms-azure/src/config.rs new file mode 100644 index 0000000000..d63513486a --- /dev/null +++ b/packages/stack-kms-azure/src/config.rs @@ -0,0 +1,362 @@ +//! The key identifiers a client accepts, the Azure clouds it knows, and its +//! settings. + +/// 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 Key Vault REST API version of every request. +/// +/// 7.4 is a GA version that Key Vault and Managed HSM both serve, and it has +/// every operation this client uses (`GET /keys`, `wrapkey`, `unwrapkey`). +pub const API_VERSION: &str = "7.4"; + +/// Key Vault or Managed HSM. Both speak the same REST API; they differ in +/// the DNS suffix and the token scope. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Flavour { + /// Azure Key Vault (`{vault}.vault.azure.net`): RSA keys, so + /// RSA-OAEP-256. + KeyVault, + /// Azure Key Vault Managed HSM (`{hsm}.managedhsm.azure.net`): `oct-HSM` + /// keys, so A256KW (or RSA-HSM keys, so RSA-OAEP-256). + ManagedHsm, +} + +/// The DNS suffixes of the Azure clouds this client sends to, with the +/// flavour and the token scope of each. A key identifier on any other host +/// is refused, so a keyset record cannot send a bearer token elsewhere. +pub const KNOWN_SUFFIXES: [(&str, Flavour, &str); 6] = [ + ( + "vault.azure.net", + Flavour::KeyVault, + "https://vault.azure.net/.default", + ), + ( + "vault.azure.cn", + Flavour::KeyVault, + "https://vault.azure.cn/.default", + ), + ( + "vault.usgovcloudapi.net", + Flavour::KeyVault, + "https://vault.usgovcloudapi.net/.default", + ), + ( + "managedhsm.azure.net", + Flavour::ManagedHsm, + "https://managedhsm.azure.net/.default", + ), + ( + "managedhsm.azure.cn", + Flavour::ManagedHsm, + "https://managedhsm.azure.cn/.default", + ), + ( + "managedhsm.usgovcloudapi.net", + Flavour::ManagedHsm, + "https://managedhsm.usgovcloudapi.net/.default", + ), +]; + +/// The token scope of Azure Key Vault in the public cloud. +pub const KEY_VAULT_SCOPE: &str = "https://vault.azure.net/.default"; +/// The token scope of Managed HSM in the public cloud. +pub const MANAGED_HSM_SCOPE: &str = "https://managedhsm.azure.net/.default"; + +/// A parsed Azure key identifier: +/// `https://{vault}.vault.azure.net/keys/{name}` or +/// `https://{hsm}.managedhsm.azure.net/keys/{name}`, optionally followed by +/// `/{version}`. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct KeyId { + vault: String, + name: String, + version: Option, + flavour: Flavour, + scope: &'static str, +} + +/// A vault, HSM or key name: `[A-Za-z0-9-]`, 1 to 127 characters. +fn is_name(text: &str) -> bool { + !text.is_empty() + && text.len() <= 127 + && text.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') +} + +/// A key version: `[A-Za-z0-9]`, 1 to [`MAX_VERSION_LEN`] characters. Azure +/// issues 32 hexadecimal digits. +pub fn is_version(text: &str) -> bool { + !text.is_empty() + && text.len() <= MAX_VERSION_LEN + && text.bytes().all(|b| b.is_ascii_alphanumeric()) +} + +/// The longest key version this client accepts. +pub const MAX_VERSION_LEN: usize = 64; + +impl KeyId { + /// Parse `text`. `None` if it is not an Azure key identifier on a known + /// host ([`KNOWN_SUFFIXES`]) with no port, query, fragment or + /// credentials. The client refuses anything else before any call, so a + /// key reference cannot change the host or the request path. + pub fn parse(text: &str) -> Option { + let rest = text.strip_prefix("https://")?; + let (host, path) = rest.split_once('/')?; + let host = host.to_ascii_lowercase(); + let (vault_name, suffix) = host.split_once('.')?; + if !is_name(vault_name) || vault_name.len() > 63 { + return None; + } + let (_, flavour, scope) = KNOWN_SUFFIXES + .iter() + .find(|(known, _, _)| *known == suffix)?; + let parts: Vec<&str> = path.split('/').collect(); + let (name, version) = match parts.as_slice() { + ["keys", name] => (*name, None), + ["keys", name, version] => (*name, Some(*version)), + _ => return None, + }; + if !is_name(name) || version.is_some_and(|v| !is_version(v)) { + return None; + } + Some(Self { + vault: format!("https://{host}"), + name: name.to_owned(), + version: version.map(str::to_owned), + flavour: *flavour, + scope, + }) + } + + /// The vault or HSM origin, `https://{host}`, lower case. + pub fn vault(&self) -> &str { + &self.vault + } + + /// The key name. + pub fn name(&self) -> &str { + &self.name + } + + /// The pinned version, if the identifier names one. + pub fn version(&self) -> Option<&str> { + self.version.as_deref() + } + + /// Key Vault or Managed HSM. + pub fn flavour(&self) -> Flavour { + self.flavour + } + + /// The token scope of the host: [`KEY_VAULT_SCOPE`], + /// [`MANAGED_HSM_SCOPE`], or the one of a national cloud. + pub fn scope(&self) -> &'static str { + self.scope + } + + /// The same key at `version`. + pub fn with_version(&self, version: &str) -> Self { + Self { + version: Some(version.to_owned()), + ..self.clone() + } + } + + /// The URL of the key (with its version, if any) and `operation` + /// (`wrapkey`), or of the key itself when `operation` is `None`, with + /// the API version. + pub fn url(&self, operation: Option<&str>) -> String { + let mut url = format!("{}/keys/{}", self.vault, self.name); + if let Some(version) = &self.version { + url.push('/'); + url.push_str(version); + } + if let Some(operation) = operation { + url.push('/'); + url.push_str(operation); + } + url.push_str("?api-version="); + url.push_str(API_VERSION); + url + } + + /// The identifier text: `{vault}/keys/{name}[/{version}]`. + pub fn to_kid(&self) -> String { + match &self.version { + Some(version) => format!("{}/keys/{}/{version}", self.vault, self.name), + None => format!("{}/keys/{}", self.vault, self.name), + } + } +} + +/// The key wrap algorithm of a key. The stored wrapped key names it, so an +/// unwrap needs no key read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum WrapAlgorithm { + /// RSA-OAEP with SHA-256 and MGF1 with SHA-256, for `RSA` and `RSA-HSM` + /// keys. Stored as `1`. + RsaOaep256, + /// AES key wrap (RFC 3394) with a 256-bit key, for Managed HSM `oct-HSM` + /// keys. Stored as `2`. + A256Kw, +} + +impl WrapAlgorithm { + /// The JSON Web Algorithm name Azure takes (`RSA-OAEP-256`, `A256KW`). + pub fn name(self) -> &'static str { + match self { + Self::RsaOaep256 => "RSA-OAEP-256", + Self::A256Kw => "A256KW", + } + } + + /// The byte of the stored wrapped key. + pub fn byte(self) -> u8 { + match self { + Self::RsaOaep256 => 1, + Self::A256Kw => 2, + } + } + + /// The algorithm of a stored byte. + pub fn from_byte(byte: u8) -> Option { + match byte { + 1 => Some(Self::RsaOaep256), + 2 => Some(Self::A256Kw), + _ => None, + } + } + + /// The algorithm of a key of JSON Web Key type `kty`, or `None` when the + /// type cannot wrap a key here (for example `EC`). + pub fn for_key_type(kty: &str) -> Option { + match kty { + "RSA" | "RSA-HSM" => Some(Self::RsaOaep256), + "oct" | "oct-HSM" => Some(Self::A256Kw), + _ => None, + } + } +} + +/// The settings of an [`AzureKeyVault`](crate::AzureKeyVault) client. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AzureKeyVaultConfig { + algorithm: Option, +} + +impl AzureKeyVaultConfig { + /// The default: the client reads each key once (`GET /keys/{name}`) to + /// learn its type, and its current version when the key identifier has + /// none. + pub fn new() -> Self { + Self::default() + } + + /// Use `algorithm` for every key. With a versioned key identifier the + /// client then reads no key before a wrap. A key whose type does not + /// match is refused. + pub fn with_algorithm(mut self, algorithm: WrapAlgorithm) -> Self { + self.algorithm = Some(algorithm); + self + } + + /// The configured algorithm, if any. + pub fn algorithm(&self) -> Option { + self.algorithm + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VAULT_KEY: &str = "https://my-vault.vault.azure.net/keys/users"; + const HSM_KEY: &str = + "https://my-hsm.managedhsm.azure.net/keys/users/0123456789abcdef0123456789abcdef"; + + #[test] + fn key_ids_parse() { + let id = KeyId::parse(VAULT_KEY).unwrap(); + assert_eq!(id.vault(), "https://my-vault.vault.azure.net"); + assert_eq!(id.name(), "users"); + assert_eq!(id.version(), None); + assert_eq!(id.flavour(), Flavour::KeyVault); + assert_eq!(id.scope(), KEY_VAULT_SCOPE); + assert_eq!(id.to_kid(), VAULT_KEY); + assert_eq!( + id.url(None), + "https://my-vault.vault.azure.net/keys/users?api-version=7.4" + ); + assert_eq!( + id.with_version("abc").url(Some("wrapkey")), + "https://my-vault.vault.azure.net/keys/users/abc/wrapkey?api-version=7.4" + ); + + let id = KeyId::parse(HSM_KEY).unwrap(); + assert_eq!(id.flavour(), Flavour::ManagedHsm); + assert_eq!(id.scope(), MANAGED_HSM_SCOPE); + assert_eq!(id.version(), Some("0123456789abcdef0123456789abcdef")); + assert_eq!(id.to_kid(), HSM_KEY); + + let id = KeyId::parse("https://My-Vault.VAULT.azure.cn/keys/k").unwrap(); + assert_eq!(id.vault(), "https://my-vault.vault.azure.cn"); + assert_eq!(id.scope(), "https://vault.azure.cn/.default"); + let id = KeyId::parse("https://h.managedhsm.usgovcloudapi.net/keys/k").unwrap(); + assert_eq!(id.scope(), "https://managedhsm.usgovcloudapi.net/.default"); + } + + #[test] + fn anything_else_is_refused() { + for bad in [ + "", + "users", + "http://my-vault.vault.azure.net/keys/users", + "https://my-vault.vault.azure.net/keys/", + "https://my-vault.vault.azure.net/keys/users/", + "https://my-vault.vault.azure.net/keys/users/v1/wrapkey", + "https://my-vault.vault.azure.net/secrets/users", + "https://my-vault.vault.azure.net/keys/us_ers", + "https://my-vault.vault.azure.net/keys/users?x=1", + "https://my-vault.vault.azure.net/keys/users#x", + "https://my-vault.vault.azure.net/keys/users/ab-cd", + "https://my-vault.vault.azure.net:443/keys/users", + "https://user@my-vault.vault.azure.net/keys/users", + "https://my-vault.vault.azure.net.evil.example/keys/users", + "https://evil.example/keys/users", + "https://vault.azure.net/keys/users", + "https://a.b.vault.azure.net/keys/users", + "https://my-vault.vault.azure.net/keys/../users", + "https://my-vault.vault.azure.net//keys/users", + ] { + assert_eq!(KeyId::parse(bad), None, "{bad:?}"); + } + let long = format!("https://v.vault.azure.net/keys/k/{}", "a".repeat(65)); + assert_eq!(KeyId::parse(&long), None); + } + + #[test] + fn algorithms_round_trip() { + for algorithm in [WrapAlgorithm::RsaOaep256, WrapAlgorithm::A256Kw] { + assert_eq!(WrapAlgorithm::from_byte(algorithm.byte()), Some(algorithm)); + } + assert_eq!(WrapAlgorithm::from_byte(0), None); + assert_eq!(WrapAlgorithm::from_byte(3), None); + assert_eq!(WrapAlgorithm::RsaOaep256.name(), "RSA-OAEP-256"); + assert_eq!(WrapAlgorithm::A256Kw.name(), "A256KW"); + assert_eq!( + WrapAlgorithm::for_key_type("RSA-HSM"), + Some(WrapAlgorithm::RsaOaep256) + ); + assert_eq!( + WrapAlgorithm::for_key_type("oct-HSM"), + Some(WrapAlgorithm::A256Kw) + ); + assert_eq!(WrapAlgorithm::for_key_type("EC"), None); + } +} diff --git a/packages/stack-kms-azure/src/lib.rs b/packages/stack-kms-azure/src/lib.rs new file mode 100644 index 0000000000..e37981c8b0 --- /dev/null +++ b/packages/stack-kms-azure/src/lib.rs @@ -0,0 +1,165 @@ +#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +//! The Azure Key Vault and Managed HSM key vendor backend for Stack Encrypt +//! (ADR-0013, ADR-0014). +//! +//! [`AzureKeyVault`] is a small, hand-written client for the Key Vault REST +//! API ([`API_VERSION`]): `wrapkey` and `unwrapkey`, with RSA-OAEP-256 for a +//! Key Vault RSA key and A256KW for a Managed HSM `oct-HSM` key. 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 key mode on it. No Azure SDK crate is used, so this crate also +//! builds for wasm32-wasip1, the Go module's WASI guest. It does no RSA +//! itself: Azure holds the key and does the RSA operation. +//! +//! Key Vault wraps only the root key of a keyset. One `wrapkey` call +//! creates a keyset record or rewraps it. One `unwrapkey` call unwraps the +//! root key, one time for each root key TTL +//! ([`DEFAULT_ROOT_KEY_TTL`](stack_kms_vendor::DEFAULT_ROOT_KEY_TTL)). +//! Each value's data key is derived from the root key locally, so a value +//! costs no Key Vault call. +//! +//! Key Vault has **no associated data** on `wrapkey`. The client binds the +//! associated data with a digest inside the wrapped plaintext +//! ([`stack_kms_vendor::bound`]); see [`AzureKeyVault`] for the trade-off. +//! +//! # Native Rust +//! +//! ```no_run +//! # async fn example() -> Result<(), Box> { +//! use stack_kms_azure::AzureKeyVault; +//! use stack_kms_vendor::{KeysetRecord, VendorKeySource}; +//! use stack_kms::KeyMode; +//! +//! // AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET, workload +//! // identity, or the managed identity on Azure. +//! let kms = AzureKeyVault::from_env()?; +//! let record = KeysetRecord::create( +//! &kms, +//! "https://my-vault.vault.azure.net/keys/cipherstash-users", +//! KeyMode::Derived, +//! Some("users".to_owned()), +//! ) +//! .await?; +//! let source = VendorKeySource::new(kms, [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. +//! +//! # Access +//! +//! The identity needs the `wrapKey` and `unwrapKey` key permissions and, +//! unless the key identifier names a version and +//! [`AzureKeyVaultConfig::with_algorithm`] is set, `get`. With Azure RBAC, +//! the built-in role "Key Vault Crypto Service Encryption User" has all +//! three (on Managed HSM, "Managed HSM Crypto Service Encryption User"). The +//! key's permitted operations must include `wrapKey` and `unwrapKey`. +//! Rotate the key in Key Vault: a keyset record names the version that +//! wrapped its root key ([`wrapped_key_version`]), and an unwrap still works +//! under an old version until you disable it. +//! +//! # The WASI guest +//! +//! The guest builds [`AzureKeyVault::new`] with its host transport and a +//! [`TokenProvider`] over a host import. Workload identity and managed +//! identity are native only: in the guest, the host's own credentials +//! supply 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::{ + AccessToken, AuthError, AzureCredentials, CachedToken, ClientSecretLogin, TokenProvider, + ACCESS_TOKEN_VARIABLE, DEFAULT_AUTHORITY_HOST, DEFAULT_REFRESH_BEFORE_EXPIRY, +}; +#[cfg(not(target_arch = "wasm32"))] +pub use auth::{DefaultCredentials, ManagedIdentityLogin, WorkloadIdentityLogin}; +pub use client::{ + compact_ciphertext, error_kind, retry_after, split_wrapped_key, wrap_plaintext, + wrapped_key_version, AzureKeyVault, AzureKeyVaultError, WrappedKeyParts, KEY_STATE_CODES, + MAX_RETRY_AFTER, WRAPPED_KEY_FORMAT, WRAP_PLAINTEXT_LEN, +}; +pub use config::{ + is_version, process_env, AzureKeyVaultConfig, EnvLookup, Flavour, KeyId, WrapAlgorithm, + API_VERSION, KEY_VAULT_SCOPE, KNOWN_SUFFIXES, MANAGED_HSM_SCOPE, MAX_VERSION_LEN, +}; +pub use time::{system_clock, Clock}; + +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +mod native { + use std::time::Duration; + + use stack_kms_vendor::{HttpVendorTransport, HttpVendorTransportOptions}; + + use crate::auth::DefaultCredentials; + use crate::{AzureKeyVault, AzureKeyVaultConfig}; + + /// [`AzureKeyVault::from_env`] failed. + #[derive(Debug, thiserror::Error)] + pub enum FromEnvError { + /// The credentials. + #[error(transparent)] + Auth(#[from] crate::AuthError), + /// The HTTP client. + #[error(transparent)] + Transport(#[from] stack_kms_vendor::TransportError), + } + + /// The client [`AzureKeyVault::from_env`] builds. + pub type DefaultAzureKeyVault = + AzureKeyVault>; + + impl DefaultAzureKeyVault { + /// A client with the credentials of [`DefaultCredentials::from_env`]. + /// Native only. + /// + /// The credentials transport opens connections with a 1 second + /// limit, so that off Azure the IMDS probe fails fast. + pub fn from_env() -> Result { + Self::from_env_with(AzureKeyVaultConfig::new()) + } + + /// As [`from_env`](Self::from_env), with `config`. + pub fn from_env_with(config: AzureKeyVaultConfig) -> Result { + let credentials_transport = + HttpVendorTransport::with_options(HttpVendorTransportOptions { + timeout: Duration::from_secs(10), + connect_timeout: Some(Duration::from_secs(1)), + })?; + let auth = DefaultCredentials::from_env(credentials_transport)?; + Ok(AzureKeyVault::new( + config, + HttpVendorTransport::new()?, + auth, + )) + } + } +} + +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +pub use native::{DefaultAzureKeyVault, FromEnvError}; diff --git a/packages/stack-kms-azure/src/time.rs b/packages/stack-kms-azure/src/time.rs new file mode 100644 index 0000000000..e1e7b26c74 --- /dev/null +++ b/packages/stack-kms-azure/src/time.rs @@ -0,0 +1,14 @@ +//! The time source of a client. + +use web_time::{SystemTime, UNIX_EPOCH}; + +/// The time source of a token cache: `SystemTime::now` unless a test sets +/// another. +pub use stack_kms_vendor::{system_clock, Clock}; + +/// Seconds since the Unix epoch. A time before the epoch is 0. +pub(crate) fn unix_seconds(time: SystemTime) -> u64 { + time.duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or(0) +} diff --git a/packages/stack-kms-azure/tasks.toml b/packages/stack-kms-azure/tasks.toml new file mode 100644 index 0000000000..92577c2eb1 --- /dev/null +++ b/packages/stack-kms-azure/tasks.toml @@ -0,0 +1,32 @@ +# 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-azure"] +description = "Build docs for stack-kms-azure with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-kms-azure --no-deps --all-features" + +# The Azure backend against real Azure Key Vault (tests/live.rs). Microsoft +# publishes no Key Vault emulator, so this needs a real key and credentials. +# The test skips itself when STACK_KMS_AZURE_LIVE_KEY 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 the key and the credentials. +# integration-azure-key-vault.yml (opt-in, credentialed) calls this task. +# +# STACK_KMS_AZURE_LIVE_KEY=https://.vault.azure.net/keys/ \ +# STACK_KMS_AZURE_LIVE_HSM_KEY=https://.managedhsm.azure.net/keys/ \ # optional +# AZURE_TENANT_ID=... AZURE_CLIENT_ID=... AZURE_CLIENT_SECRET=... \ +# mise run test:live:stack-kms-azure +["test:live:stack-kms-azure"] +description = "Run the stack-kms-azure tests against real Azure Key Vault (needs STACK_KMS_AZURE_LIVE_KEY and credentials)" +run = """ +if [ -z "${STACK_KMS_AZURE_LIVE_KEY:-}" ]; then + echo "error: set STACK_KMS_AZURE_LIVE_KEY to a key identifier (https://.vault.azure.net/keys/)" >&2 + exit 1 +fi +if [ -z "${AZURE_CLIENT_SECRET:-}" ] && [ -z "${AZURE_KEY_VAULT_ACCESS_TOKEN:-}" ] && [ -z "${AZURE_FEDERATED_TOKEN_FILE:-}" ]; then + echo "error: set AZURE_TENANT_ID, AZURE_CLIENT_ID and AZURE_CLIENT_SECRET (or AZURE_KEY_VAULT_ACCESS_TOKEN)" >&2 + exit 1 +fi +mise x --env test -- cargo nextest run --locked -p stack-kms-azure --all-features --test live --no-tests=fail +""" diff --git a/packages/stack-kms-azure/tests/common/mod.rs b/packages/stack-kms-azure/tests/common/mod.rs new file mode 100644 index 0000000000..3dbd1a1d23 --- /dev/null +++ b/packages/stack-kms-azure/tests/common/mod.rs @@ -0,0 +1,522 @@ +//! Shared test support: the test-only RSA key, an RFC 3394 key wrap, and an +//! in-process emulation of the Key Vault and Managed HSM key API and the +//! Entra ID token endpoint. The fixture and replay 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::cipher::generic_array::GenericArray; +use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit}; +use aes::Aes256; +use base64ct::{Base64, Base64UrlUnpadded, Encoding}; +use rsa::pkcs8::DecodePrivateKey; +use rsa::sha2::Sha256; +use rsa::{Oaep, RsaPrivateKey}; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; +use stack_kms_azure::{AccessToken, AzureKeyVault, AzureKeyVaultConfig, KeyId}; +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, Replay}; +use stack_kms_vendor::{TransportError, VendorRequest, VendorResponse, VendorTransport}; +use web_time::{SystemTime, UNIX_EPOCH}; + +/// The Key Vault of the fixtures. +pub const VAULT: &str = "https://cipherstash-fixture.vault.azure.net"; +/// The Managed HSM of the fixtures. +pub const HSM: &str = "https://cipherstash-fixture.managedhsm.azure.net"; +/// The Key Vault RSA key of the fixtures (no version). +pub const RSA_KEY: &str = "https://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key"; +/// Its current version in the fixtures. +pub const RSA_VERSION: &str = "1a2b3c4d5e6f708192a3b4c5d6e7f801"; +/// An older version, which `vault_wrap_pinned.json` names. +pub const RSA_OLD_VERSION: &str = "0a1b2c3d4e5f60718293a4b5c6d7e8f9"; +/// The Managed HSM `oct-HSM` key of the fixtures (no version). +pub const OCT_KEY: &str = "https://cipherstash-fixture.managedhsm.azure.net/keys/fixture-oct-key"; +/// Its current version in the fixtures. +pub const OCT_VERSION: &str = "5c6d7e8f90a1b2c3d4e5f60718293a4b"; +/// The static access token of the fixtures. +pub const TOKEN: &str = "fixture-access-token"; +/// The fixture service principal. +pub const TENANT: &str = "test-only-tenant"; +pub const CLIENT_ID: &str = "test-only-client-id"; +pub const CLIENT_SECRET: &str = "fixture-client-secret"; +/// The Entra ID token endpoint of the fixture tenant. +pub const TOKEN_URL: &str = "https://login.microsoftonline.com/test-only-tenant/oauth2/v2.0/token"; +/// The time of the credential fixtures: 2026-11-05T12:00:00Z. +pub const ISSUED_AT: u64 = 1_793_880_000; + +pub fn issued_at() -> SystemTime { + UNIX_EPOCH + Duration::from_secs(ISSUED_AT) +} + +/// The TEST-ONLY 256-bit AES key of the fixture `oct-HSM` key version: the +/// RFC 3394 example key (`00 01 .. 1F`). It is no one's key. +pub const TEST_ONLY_VENDOR_KEY: [u8; 32] = [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, + 26, 27, 28, 29, 30, 31, +]; + +/// The TEST-ONLY 2048-bit RSA key of the fixture RSA key, generated for +/// these tests with `openssl genpkey -algorithm RSA -pkeyopt +/// rsa_keygen_bits:2048`. It is no one's key. It is kept as base64 PKCS #8 +/// DER, not as PEM or in a key file, so it does not look like a leaked +/// credential. +pub fn test_only_rsa_key() -> RsaPrivateKey { + let path = format!( + "{}/tests/fixtures/test_only_rsa_key.pkcs8.b64", + env!("CARGO_MANIFEST_DIR") + ); + let text = std::fs::read_to_string(path).unwrap(); + let body: String = text.split_whitespace().collect(); + RsaPrivateKey::from_pkcs8_der(&Base64::decode_vec(&body).unwrap()).unwrap() +} + +const KW_IV: [u8; 8] = [0xA6; 8]; + +/// AES key wrap (RFC 3394) under a 256-bit key: an implementation for the +/// emulator, pinned by the RFC's own example in `tests/fixtures.rs`. +pub fn kw_wrap(vendor_key: &[u8; 32], plaintext: &[u8]) -> Vec { + assert!(plaintext.len().is_multiple_of(8) && plaintext.len() >= 16); + let cipher = Aes256::new(GenericArray::from_slice(vendor_key)); + let n = plaintext.len() / 8; + let mut a = KW_IV; + let mut r: Vec<[u8; 8]> = plaintext.chunks(8).map(|c| c.try_into().unwrap()).collect(); + for j in 0..6 { + for (i, block) in r.iter_mut().enumerate() { + let mut b = GenericArray::clone_from_slice(&[a, *block].concat()); + cipher.encrypt_block(&mut b); + let t = (n * j + i + 1) as u64; + a = (u64::from_be_bytes(b[..8].try_into().unwrap()) ^ t).to_be_bytes(); + block.copy_from_slice(&b[8..]); + } + } + let mut out = a.to_vec(); + for block in r { + out.extend_from_slice(&block); + } + out +} + +/// AES key unwrap (RFC 3394). `None` when the integrity check fails. +pub fn kw_unwrap(vendor_key: &[u8; 32], ciphertext: &[u8]) -> Option> { + if !ciphertext.len().is_multiple_of(8) || ciphertext.len() < 24 { + return None; + } + let cipher = Aes256::new(GenericArray::from_slice(vendor_key)); + let n = ciphertext.len() / 8 - 1; + let mut a: [u8; 8] = ciphertext[..8].try_into().unwrap(); + let mut r: Vec<[u8; 8]> = ciphertext[8..] + .chunks(8) + .map(|c| c.try_into().unwrap()) + .collect(); + for j in (0..6).rev() { + for i in (0..n).rev() { + let t = (n * j + i + 1) as u64; + let a_t = (u64::from_be_bytes(a) ^ t).to_be_bytes(); + let mut b = GenericArray::clone_from_slice(&[a_t, r[i]].concat()); + cipher.decrypt_block(&mut b); + a.copy_from_slice(&b[..8]); + r[i].copy_from_slice(&b[8..]); + } + } + (a == KW_IV).then(|| r.concat()) +} + +/// The fixtures of this crate. +pub const FIXTURES: Fixtures = + Fixtures::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures")); + +pub fn load(name: &str) -> T { + FIXTURES.load(name) +} + +pub fn replay(name: &str) -> Replay { + FIXTURES.replay(name) +} + +/// A client with the default configuration over `transport`, with the +/// fixture token. +pub fn fixture_client( + transport: T, +) -> AzureKeyVault { + AzureKeyVault::new( + AzureKeyVaultConfig::new(), + transport, + AccessToken::new(TOKEN), + ) +} + +/// The key material of one key version in the emulator. +#[derive(Clone)] +enum Material { + /// The test-only RSA key: every RSA version uses it. + Rsa, + Aes([u8; 32]), +} + +#[derive(Clone)] +struct EmulatedKey { + rsa: bool, + enabled: bool, + /// Versions, oldest first; the last is current. + versions: Vec<(String, Material, bool)>, +} + +#[derive(Default)] +struct EmulatorState { + /// Keys by `{vault}/keys/{name}`. + keys: HashMap, + /// Access tokens and their scope (`None`: every scope). + tokens: HashMap>, + exchanges: usize, + requests: Vec<(String, String, Value)>, + sleeps: Vec, + throttle: usize, + versions: u32, +} + +/// An in-process emulation of the Key Vault and Managed HSM key API (`GET +/// /keys`, `wrapkey`, `unwrapkey`; RSA-OAEP-256 with the test-only RSA key, +/// A256KW with per-version AES keys; versions, rotation and disabled +/// versions; Azure's error shapes) and of the Entra ID token endpoint (the +/// client credentials grant with [`CLIENT_SECRET`]). It checks the bearer +/// token and its scope on every key request. +/// +/// The contract suite uses random keys and ids, so fixed exchanges cannot +/// serve it: this answers it instead. Clones share one state. +#[derive(Clone)] +pub struct KeyVaultEmulator { + state: Arc>, + rsa: Arc, +} + +impl std::fmt::Debug for KeyVaultEmulator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("KeyVaultEmulator") + } +} + +impl Default for KeyVaultEmulator { + fn default() -> Self { + Self::new() + } +} + +impl KeyVaultEmulator { + /// An emulator holding [`RSA_KEY`] and [`OCT_KEY`] (one version each), + /// that accepts [`TOKEN`] for every scope. + pub fn new() -> Self { + let emulator = Self { + state: Arc::default(), + rsa: Arc::new(test_only_rsa_key()), + }; + emulator.add_key(RSA_KEY, true); + emulator.add_key(OCT_KEY, false); + let _ = emulator + .state + .lock() + .unwrap() + .tokens + .insert(TOKEN.to_owned(), None); + emulator + } + + /// Add a key (`{vault}/keys/{name}`) with one version. + pub fn add_key(&self, key: &str, rsa: bool) { + let _ = self.state.lock().unwrap().keys.insert( + key.to_owned(), + EmulatedKey { + rsa, + enabled: true, + versions: Vec::new(), + }, + ); + self.rotate(key); + } + + /// Add a version to `key` and make it current. Returns the version. + pub fn rotate(&self, key: &str) -> String { + let mut state = self.state.lock().unwrap(); + state.versions += 1; + let version = format!("{:032x}", 0xabc0_0000u64 + u64::from(state.versions)); + let seed = state.versions as u8; + let entry = state.keys.get_mut(key).unwrap(); + let material = if entry.rsa { + Material::Rsa + } else { + Material::Aes([seed ^ 0x5A; 32]) + }; + entry.versions.push((version.clone(), material, true)); + version + } + + /// Disable one version of `key`. + pub fn disable(&self, key: &str, version: &str) { + let mut state = self.state.lock().unwrap(); + for entry in &mut state.keys.get_mut(key).unwrap().versions { + if entry.0 == version { + entry.2 = false; + } + } + } + + /// Answer the next `n` key requests with HTTP 429 and `Retry-After: 2`. + pub fn throttle(&self, n: usize) { + self.state.lock().unwrap().throttle = n; + } + + /// Revoke every access token. + pub fn revoke_tokens(&self) { + self.state.lock().unwrap().tokens.clear(); + } + + /// Token endpoint exchanges so far. + pub fn exchanges(&self) -> usize { + self.state.lock().unwrap().exchanges + } + + /// Every key request: method, URL, body. + pub fn requests(&self) -> Vec<(String, String, Value)> { + self.state.lock().unwrap().requests.clone() + } + + pub fn sleeps(&self) -> Vec { + self.state.lock().unwrap().sleeps.clone() + } + + fn json(status: u16, body: Value) -> VendorResponse { + VendorResponse::new(status, body.to_string().into_bytes()) + .header("content-type", "application/json; charset=utf-8") + } + + fn error(status: u16, code: &str, inner: Option<&str>, message: &str) -> VendorResponse { + let mut error = json!({ "code": code, "message": message }); + if let Some(inner) = inner { + error["innererror"] = json!({ "code": inner }); + } + Self::json(status, json!({ "error": error })) + } + + fn token_exchange(&self, body: &[u8]) -> VendorResponse { + let form: HashMap = + url::form_urlencoded::parse(body).into_owned().collect(); + let field = |name: &str| form.get(name).map(String::as_str); + if field("grant_type") != Some("client_credentials") + || field("client_id") != Some(CLIENT_ID) + { + return Self::json(400, json!({ "error": "unauthorized_client" })); + } + if field("client_secret") != Some(CLIENT_SECRET) { + return Self::json( + 401, + json!({ + "error": "invalid_client", + "error_description": "AADSTS7000215: Invalid client secret provided.", + }), + ); + } + let Some(scope) = field("scope").map(str::to_owned) else { + return Self::json(400, json!({ "error": "invalid_scope" })); + }; + let mut state = self.state.lock().unwrap(); + state.exchanges += 1; + let token = format!("fixture-exchanged-token-{}", state.exchanges); + let _ = state.tokens.insert(token.clone(), Some(scope)); + Self::json( + 200, + json!({ "token_type": "Bearer", "expires_in": 3599, "access_token": token }), + ) + } + + fn get_key(&self, key: &str, version: Option<&str>) -> VendorResponse { + let state = self.state.lock().unwrap(); + let Some(entry) = state.keys.get(key) else { + return Self::error( + 404, + "KeyNotFound", + None, + "A key with (name/id) was not found.", + ); + }; + let found = match version { + Some(version) => entry.versions.iter().find(|v| v.0 == version), + None => entry.versions.last(), + }; + let Some((version, _, enabled)) = found else { + return Self::error( + 404, + "KeyNotFound", + None, + "A key with (name/id) was not found.", + ); + }; + let kty = if entry.rsa { "RSA" } else { "oct-HSM" }; + Self::json( + 200, + json!({ + "key": { + "kid": format!("{key}/{version}"), + "kty": kty, + "key_ops": ["wrapKey", "unwrapKey"], + }, + "attributes": { "enabled": *enabled && entry.enabled }, + }), + ) + } + + fn key_operation( + &self, + key: &str, + version: &str, + operation: &str, + body: &Value, + ) -> VendorResponse { + let (rsa, material, enabled) = { + let state = self.state.lock().unwrap(); + let Some(entry) = state.keys.get(key) else { + return Self::error( + 404, + "KeyNotFound", + None, + "A key with (name/id) was not found.", + ); + }; + let Some((_, material, enabled)) = entry.versions.iter().find(|v| v.0 == version) + else { + return Self::error( + 404, + "KeyNotFound", + None, + "A key with (name/id) was not found.", + ); + }; + (entry.rsa, material.clone(), *enabled) + }; + if !enabled { + return Self::error( + 403, + "Forbidden", + Some("KeyDisabled"), + "Operation is not allowed on a disabled key.", + ); + } + let expected_alg = if rsa { "RSA-OAEP-256" } else { "A256KW" }; + if body["alg"] != expected_alg { + return Self::error(400, "BadParameter", None, "Invalid algorithm for this key."); + } + let Some(value) = body["value"] + .as_str() + .and_then(|v| Base64UrlUnpadded::decode_vec(v).ok()) + else { + return Self::error( + 400, + "BadParameter", + None, + "Property value is not base64url.", + ); + }; + let result = match (operation, &material) { + ("wrapkey", Material::Rsa) => Some( + self.rsa + .to_public_key() + .encrypt(&mut rand_core::OsRng, Oaep::new::(), &value) + .unwrap(), + ), + ("unwrapkey", Material::Rsa) => self.rsa.decrypt(Oaep::new::(), &value).ok(), + ("wrapkey", Material::Aes(vendor_key)) => (value.len().is_multiple_of(8) + && value.len() >= 16) + .then(|| kw_wrap(vendor_key, &value)), + ("unwrapkey", Material::Aes(vendor_key)) => kw_unwrap(vendor_key, &value), + _ => None, + }; + match result { + Some(out) => Self::json( + 200, + json!({ + "kid": format!("{key}/{version}"), + "value": Base64UrlUnpadded::encode_string(&out), + }), + ), + None => Self::error( + 400, + "BadParameter", + None, + "The value could not be unwrapped.", + ), + } + } +} + +impl VendorTransport for KeyVaultEmulator { + async fn send(&self, request: VendorRequest) -> Result { + if request.url == TOKEN_URL { + assert_eq!(request.method, "POST"); + return Ok(self.token_exchange(&request.body)); + } + let (path, query) = request.url.split_once('?').expect("an api-version"); + assert_eq!(query, "api-version=7.4"); + let body = body_json(&request.body); + let (kid, operation) = match path.rsplit_once('/') { + Some((kid, op @ ("wrapkey" | "unwrapkey"))) => (kid, Some(op)), + _ => (path, None), + }; + let id = KeyId::parse(kid).expect("a key identifier"); + { + let mut state = self.state.lock().unwrap(); + state + .requests + .push((request.method.clone(), request.url.clone(), body.clone())); + let token = request + .header_value("authorization") + .and_then(|value| value.strip_prefix("Bearer ")) + .unwrap_or(""); + match state.tokens.get(token) { + Some(None) => {} + Some(Some(scope)) if scope == id.scope() => {} + Some(Some(_)) => { + return Ok(Self::error( + 401, + "Unauthorized", + None, + "AKV10022: Invalid audience.", + )) + } + None => { + return Ok(Self::error( + 401, + "Unauthorized", + None, + "AKV10032: Invalid issuer.", + )) + } + } + if state.throttle > 0 { + state.throttle -= 1; + return Ok(Self::error(429, "Throttled", None, "Too many requests.") + .header("retry-after", "2")); + } + } + let key = format!("{}/keys/{}", id.vault(), id.name()); + Ok(match (request.method.as_str(), operation) { + ("GET", None) => self.get_key(&key, id.version()), + ("POST", Some(operation)) => { + assert_eq!( + request.header_value("content-type"), + Some("application/json") + ); + self.key_operation(&key, id.version().expect("a version"), operation, &body) + } + _ => Self::error(405, "MethodNotAllowed", None, "Method not allowed."), + }) + } + + async fn sleep(&self, duration: Duration) { + self.state.lock().unwrap().sleeps.push(duration); + } +} diff --git a/packages/stack-kms-azure/tests/contract.rs b/packages/stack-kms-azure/tests/contract.rs new file mode 100644 index 0000000000..c5a7f310f9 --- /dev/null +++ b/packages/stack-kms-azure/tests/contract.rs @@ -0,0 +1,241 @@ +//! The shared contract suite, run against the Azure backend over an +//! in-process emulation of the Key Vault key API, for a +//! Key Vault RSA key (RSA-OAEP-256) and a Managed HSM `oct-HSM` key +//! (A256KW). +//! +//! The suite generates random keys and keyset ids, so fixed exchanges +//! cannot answer it; the emulator does (see `common::KeyVaultEmulator`). +//! The request shapes are pinned by `tests/fixtures.rs`, and real Azure Key +//! Vault is exercised by `tests/live.rs`. + +mod common; + +use common::{ + fixture_client, KeyVaultEmulator, CLIENT_ID, CLIENT_SECRET, OCT_KEY, RSA_KEY, TENANT, +}; +use stack_encrypt::StackCipher; +use stack_kms::{BackendErrorKind, Error, KeyMode, KeyVendor}; +use stack_kms_azure::{ + wrapped_key_version, AzureKeyVault, AzureKeyVaultConfig, CachedToken, ClientSecretLogin, +}; +use stack_kms_vendor::derive::record_key_aad; +use stack_kms_vendor::{contract, KeyVendorBackend, KeysetRecord, VendorKeySource}; +use uuid::Uuid; +use vitaminc::protected::{Controlled, Protected}; + +fn kind_of(error: Error) -> BackendErrorKind { + match error { + Error::Backend { backend, kind, .. } => { + assert_eq!(backend.name(), "azure-key-vault"); + kind + } + other => panic!("not a backend error: {other:?}"), + } +} + +#[tokio::test] +async fn a_key_vault_rsa_key_meets_the_contract() { + let emulator = KeyVaultEmulator::new(); + contract::run(|| fixture_client(emulator.clone()), RSA_KEY).await; +} + +#[tokio::test] +async fn a_managed_hsm_oct_key_meets_the_contract() { + let emulator = KeyVaultEmulator::new(); + contract::run(|| fixture_client(emulator.clone()), OCT_KEY).await; +} + +#[tokio::test] +async fn both_flavours_meet_the_contract_with_client_secret_credentials() { + let emulator = KeyVaultEmulator::new(); + let make = || { + AzureKeyVault::new( + AzureKeyVaultConfig::new(), + emulator.clone(), + CachedToken::new( + ClientSecretLogin::new(TENANT, CLIENT_ID, CLIENT_SECRET, emulator.clone()).unwrap(), + ), + ) + }; + contract::run(make, RSA_KEY).await; + contract::run(make, OCT_KEY).await; + // The emulator checks the audience: a vault token does not open the HSM. + assert!(emulator.exchanges() >= 2); +} + +#[tokio::test] +async fn a_wrapped_key_opens_only_under_its_own_aad() { + for key in [RSA_KEY, OCT_KEY] { + let emulator = KeyVaultEmulator::new(); + let kms = fixture_client(emulator.clone()); + let id = Uuid::from_u128(1); + let aad = record_key_aad(id, KeyVendor::Azure, KeyMode::Derived); + let wrapped = kms + .wrap(key, &Protected::new([9u8; 32]), &aad) + .await + .unwrap(); + assert_eq!( + kms.unwrap(key, &wrapped, &aad).await.unwrap().risky_ref(), + &[9u8; 32] + ); + // Azure unwraps it (it sees no associated data); the client refuses. + let other = record_key_aad(Uuid::from_u128(2), KeyVendor::Azure, KeyMode::Derived); + let error = kms.unwrap(key, &wrapped, &other).await.unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::InvalidCiphertext); + // A damaged ciphertext does not unwrap at Azure. + let mut damaged = wrapped.clone(); + let last = damaged.len() - 1; + damaged[last] ^= 1; + let error = kms.unwrap(key, &damaged, &aad).await.unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::InvalidCiphertext); + } +} + +#[tokio::test] +async fn rotation_needs_no_change_to_the_key_reference() { + let emulator = KeyVaultEmulator::new(); + let old = fixture_client(emulator.clone()) + .wrap(OCT_KEY, &Protected::new([5u8; 32]), b"aad") + .await + .unwrap(); + let new_version = emulator.rotate(OCT_KEY); + // A new client reads the key again and wraps under the new version. + let kms = fixture_client(emulator.clone()); + let new = kms + .wrap(OCT_KEY, &Protected::new([6u8; 32]), b"aad") + .await + .unwrap(); + assert_ne!(wrapped_key_version(&old), wrapped_key_version(&new)); + assert_eq!(wrapped_key_version(&new), Some(new_version.as_str())); + assert_eq!( + kms.unwrap(OCT_KEY, &old, b"aad").await.unwrap().risky_ref(), + &[5u8; 32] + ); + assert_eq!( + kms.unwrap(OCT_KEY, &new, b"aad").await.unwrap().risky_ref(), + &[6u8; 32] + ); +} + +#[tokio::test] +async fn a_failed_wrap_reads_the_key_again() { + let emulator = KeyVaultEmulator::new(); + let kms = fixture_client(emulator.clone()); + let first = kms + .wrap(OCT_KEY, &Protected::new([1u8; 32]), b"a") + .await + .unwrap(); + let old_version = wrapped_key_version(&first).unwrap().to_owned(); + // An operator rotates the key and disables the old version. + let new_version = emulator.rotate(OCT_KEY); + emulator.disable(OCT_KEY, &old_version); + let error = kms + .wrap(OCT_KEY, &Protected::new([2u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Unavailable); + let next = kms + .wrap(OCT_KEY, &Protected::new([2u8; 32]), b"a") + .await + .unwrap(); + assert_eq!(wrapped_key_version(&next), Some(new_version.as_str())); + // The old wrapped key does not unwrap while its version is disabled. + let error = kms.unwrap(OCT_KEY, &first, b"a").await.unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Unavailable); +} + +#[tokio::test] +async fn a_throttle_waits_for_retry_after_then_reports_throttled() { + let emulator = KeyVaultEmulator::new(); + let kms = fixture_client(emulator.clone()); + emulator.throttle(1); + let error = kms + .wrap(RSA_KEY, &Protected::new([1u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Throttled); + assert_eq!(emulator.sleeps(), [std::time::Duration::from_secs(2)]); + // The adapter retries a throttle; the next call goes through. + let wrapped = kms + .wrap(RSA_KEY, &Protected::new([1u8; 32]), b"a") + .await + .unwrap(); + assert!(wrapped_key_version(&wrapped).is_some()); +} + +#[tokio::test] +async fn a_missing_key_is_not_found() { + let emulator = KeyVaultEmulator::new(); + let kms = fixture_client(emulator); + let missing = "https://cipherstash-fixture.vault.azure.net/keys/missing"; + let error = kms + .wrap(missing, &Protected::new([1u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::NotFound); +} + +#[tokio::test] +async fn a_record_rewraps_to_a_new_version_and_keeps_old_values() { + for key in [RSA_KEY, OCT_KEY] { + for pin in [false, true] { + let emulator = KeyVaultEmulator::new(); + let record = KeysetRecord::create( + &fixture_client(emulator.clone()), + key, + KeyMode::Derived, + None, + ) + .await + .unwrap(); + let old = StackCipher::builder() + .kms( + VendorKeySource::new(fixture_client(emulator.clone()), [record.clone()]) + .unwrap(), + ) + .init() + .await + .unwrap(); + let sealed = old + .default_keyset() + .encrypt_tree("v".to_string(), b"ctx") + .await + .unwrap(); + let version = emulator.rotate(key); + let new_ref = if pin { + format!("{key}/{version}") + } else { + key.to_owned() + }; + // A new client, so it reads the current version. + let rewrapped = record + .rewrap(&fixture_client(emulator.clone()), new_ref) + .await + .unwrap(); + assert_eq!( + wrapped_key_version(rewrapped.wrapped_key()), + Some(version.as_str()) + ); + let new = StackCipher::builder() + .kms(VendorKeySource::new(fixture_client(emulator.clone()), [rewrapped]).unwrap()) + .init() + .await + .unwrap(); + let opened: String = new.decrypt(sealed, b"ctx").await.unwrap(); + assert_eq!(opened, "v"); + } + } +} + +#[tokio::test] +async fn a_record_may_rewrap_to_another_key() { + let emulator = KeyVaultEmulator::new(); + let other = RSA_KEY.replace("fixture-rsa-key", "other-rsa-key"); + emulator.add_key(&other, true); + let kms = fixture_client(emulator.clone()); + let record = KeysetRecord::create(&kms, RSA_KEY, KeyMode::Derived, None) + .await + .unwrap(); + let moved = record.rewrap(&kms, other.as_str()).await.unwrap(); + assert_eq!(moved.key_ref(), other); +} diff --git a/packages/stack-kms-azure/tests/fixtures.rs b/packages/stack-kms-azure/tests/fixtures.rs new file mode 100644 index 0000000000..b022239534 --- /dev/null +++ b/packages/stack-kms-azure/tests/fixtures.rs @@ -0,0 +1,714 @@ +//! Each Key Vault call, each credential exchange and each error mapping, +//! against fixed HTTP exchanges (`tests/fixtures/*.json`). +//! +//! Microsoft publishes no Key Vault emulator, so no fixture was recorded +//! from Azure. Every response is written by hand in the documented Key +//! Vault, Entra ID and managed identity shapes. Every request side was +//! computed by a separate script, independently of this client (base64url, +//! the associated data frame, the bound key digest, the form bodies), and +//! the client must send exactly those bytes. The RSA-OAEP-256 ciphertexts +//! were made by `openssl pkeyutl` under a TEST-ONLY RSA key generated for +//! these tests (`tests/fixtures/test_only_rsa_key.pkcs8.b64`), and the +//! A256KW ones by an RFC 3394 implementation over `openssl enc +//! -aes-256-ecb`, under the RFC's example key. Neither is anyone's key. +//! `tests/live.rs` runs against real Azure Key Vault. + +mod common; + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use base64ct::{Base64UrlUnpadded, Encoding}; +use common::{ + fixture_client, issued_at, kw_unwrap, kw_wrap, load, replay, test_only_rsa_key, Fixture, + FixtureResponse, KeyVaultEmulator, Replay, CLIENT_ID, CLIENT_SECRET, OCT_KEY, OCT_VERSION, + RSA_KEY, RSA_OLD_VERSION, RSA_VERSION, TENANT, TEST_ONLY_VENDOR_KEY, TOKEN, +}; +use rsa::sha2::Sha256; +use rsa::Oaep; +use serde::Deserialize; +use stack_kms::{BackendErrorKind, Error, KeyMode, KeyVendor}; +use stack_kms_azure::{ + compact_ciphertext, split_wrapped_key, wrap_plaintext, wrapped_key_version, AccessToken, + AuthError, AzureKeyVault, AzureKeyVaultConfig, CachedToken, ClientSecretLogin, + DefaultCredentials, EnvLookup, ManagedIdentityLogin, TokenProvider, WorkloadIdentityLogin, + WrapAlgorithm, KEY_VAULT_SCOPE, MANAGED_HSM_SCOPE, +}; +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); + +/// The associated data of the root key of keyset `KEYSET + n`. +fn data_aad(n: u8) -> Vec { + let keyset = Uuid::from_u128(KEYSET.as_u128() + u128::from(n)); + record_key_aad(keyset, KeyVendor::Azure, KeyMode::Derived) +} + +fn kind_of(error: Error) -> BackendErrorKind { + match error { + Error::Backend { backend, kind, .. } => { + assert_eq!(backend.name(), "azure-key-vault"); + kind + } + other => panic!("not a backend error: {other:?}"), + } +} + +fn b64(value: &serde_json::Value) -> Vec { + Base64UrlUnpadded::decode_vec(value.as_str().unwrap()).unwrap() +} + +/// The stored wrapped key of a fixture's last (wrapkey) exchange. +fn wrapped_of(name: &str, algorithm: WrapAlgorithm, version: &str) -> Vec { + let fixtures: Vec = load(name); + let body = &fixtures.last().unwrap().response.body; + compact_ciphertext(algorithm, version, &b64(&body["value"])) +} + +fn rsa_wrapped() -> Vec { + wrapped_of("vault_wrap.json", WrapAlgorithm::RsaOaep256, RSA_VERSION) +} + +#[tokio::test] +async fn a_key_vault_rsa_key_wraps_and_unwraps() { + let kms = fixture_client(replay("vault_wrap.json")); + let wrapped = kms + .wrap(RSA_KEY, &Protected::new([0x42u8; 32]), &data_aad(0)) + .await + .unwrap(); + assert_eq!(wrapped, rsa_wrapped()); + assert_eq!(wrapped_key_version(&wrapped), Some(RSA_VERSION)); + assert_eq!( + split_wrapped_key(&wrapped).unwrap().algorithm, + WrapAlgorithm::RsaOaep256 + ); + assert!(kms.transport().is_done()); + + let kms = fixture_client(replay("vault_unwrap.json")); + let opened = kms.unwrap(RSA_KEY, &wrapped, &data_aad(0)).await.unwrap(); + assert_eq!(opened.risky_ref(), &[0x42u8; 32]); + assert!(kms.transport().is_done()); + + // The unwrap names the stored version, whatever the key reference pins. + let kms = fixture_client(replay("vault_unwrap.json")); + let pinned = format!("{RSA_KEY}/{RSA_OLD_VERSION}"); + let opened = kms.unwrap(&pinned, &wrapped, &data_aad(0)).await.unwrap(); + assert_eq!(opened.risky_ref(), &[0x42u8; 32]); +} + +#[tokio::test] +async fn the_key_is_read_once_per_client() { + let fixtures: Vec = load("vault_wrap.json"); + let wrap_again = fixtures[1].clone(); + let kms = fixture_client( + Replay::new() + .expect_all(fixtures) + .expect_all(vec![wrap_again]), + ); + for _ in 0..2 { + let wrapped = kms + .wrap(RSA_KEY, &Protected::new([0x42u8; 32]), &data_aad(0)) + .await + .unwrap(); + assert_eq!(wrapped, rsa_wrapped()); + } + assert!(kms.transport().is_done()); +} + +#[tokio::test] +async fn a_pinned_version_with_a_configured_algorithm_reads_no_key() { + let kms = AzureKeyVault::new( + AzureKeyVaultConfig::new().with_algorithm(WrapAlgorithm::RsaOaep256), + replay("vault_wrap_pinned.json"), + AccessToken::new(TOKEN), + ); + let pinned = format!("{RSA_KEY}/{RSA_OLD_VERSION}"); + let wrapped = kms + .wrap(&pinned, &Protected::new([0x43u8; 32]), &data_aad(1)) + .await + .unwrap(); + assert_eq!( + wrapped, + wrapped_of( + "vault_wrap_pinned.json", + WrapAlgorithm::RsaOaep256, + RSA_OLD_VERSION + ) + ); + assert!(kms.transport().is_done()); + + let kms = fixture_client(replay("vault_unwrap_pinned.json")); + let opened = kms.unwrap(RSA_KEY, &wrapped, &data_aad(1)).await.unwrap(); + assert_eq!(opened.risky_ref(), &[0x43u8; 32]); +} + +#[tokio::test] +async fn a_managed_hsm_oct_key_wraps_with_a256kw() { + let kms = fixture_client(replay("hsm_wrap.json")); + let wrapped = kms + .wrap(OCT_KEY, &Protected::new([0x44u8; 32]), &data_aad(2)) + .await + .unwrap(); + assert_eq!( + wrapped, + wrapped_of("hsm_wrap.json", WrapAlgorithm::A256Kw, OCT_VERSION) + ); + // 72 bytes wrapped by A256KW are 80. + assert_eq!(split_wrapped_key(&wrapped).unwrap().ciphertext.len(), 80); + assert!(kms.transport().is_done()); + + let kms = fixture_client(replay("hsm_unwrap.json")); + let opened = kms.unwrap(OCT_KEY, &wrapped, &data_aad(2)).await.unwrap(); + assert_eq!(opened.risky_ref(), &[0x44u8; 32]); + assert!(kms.transport().is_done()); +} + +/// The fixture ciphertexts are real: the test-only keys open them to the +/// plaintext the client sends, and the emulator's key wrap is RFC 3394's. +#[test] +fn the_fixture_ciphertexts_open_under_the_test_only_keys() { + let fixtures: Vec = load("vault_wrap.json"); + let plaintext = b64(&fixtures[1].request.body["value"]); + assert_eq!( + plaintext, + wrap_plaintext(&Protected::new([0x42u8; 32]), &data_aad(0)).to_vec() + ); + let ciphertext = b64(&fixtures[1].response.body["value"]); + let opened = test_only_rsa_key() + .decrypt(Oaep::new::(), &ciphertext) + .unwrap(); + assert_eq!(opened, plaintext); + + let fixtures: Vec = load("hsm_wrap.json"); + let plaintext = b64(&fixtures[1].request.body["value"]); + let ciphertext = b64(&fixtures[1].response.body["value"]); + assert_eq!( + kw_unwrap(&TEST_ONLY_VENDOR_KEY, &ciphertext).unwrap(), + plaintext + ); + assert_eq!(kw_wrap(&TEST_ONLY_VENDOR_KEY, &plaintext), ciphertext); + + // RFC 3394, 4.6: 256 bits of key data with a 256-bit key (the vendor key). + let key_data: Vec = (0..16u8) + .map(|i| i * 0x11) + .chain(0..16u8) + .collect::>(); + let expected = [ + 0x28, 0xC9, 0xF4, 0x04, 0xC4, 0xB8, 0x10, 0xF4, 0xCB, 0xCC, 0xB3, 0x5C, 0xFB, 0x87, 0xF8, + 0x26, 0x3F, 0x57, 0x86, 0xE2, 0xD8, 0x0E, 0xD3, 0x26, 0xCB, 0xC7, 0xF0, 0xE7, 0x1A, 0x99, + 0xF4, 0x3B, 0xFB, 0x98, 0x8B, 0x9B, 0x7A, 0x02, 0xDD, 0x21, + ]; + assert_eq!(kw_wrap(&TEST_ONLY_VENDOR_KEY, &key_data), expected); + assert_eq!( + kw_unwrap(&TEST_ONLY_VENDOR_KEY, &expected).unwrap(), + key_data + ); +} + +fn fixed_clock() -> stack_kms_azure::Clock { + Arc::new(issued_at) +} + +#[tokio::test] +async fn a_client_secret_is_exchanged_for_a_token() { + let login = ClientSecretLogin::new( + TENANT, + CLIENT_ID, + CLIENT_SECRET, + replay("client_secret_token.json"), + ) + .unwrap(); + let token = login.token(KEY_VAULT_SCOPE).await.unwrap(); + assert_eq!(token.token(), "fixture-client-secret-token"); + assert_eq!(token.expires_in(), Some(Duration::from_secs(3599))); + assert!(login.transport().is_done()); +} + +#[tokio::test] +async fn a_federated_token_is_exchanged_for_a_token() { + let path = std::env::temp_dir().join(format!("stack-kms-azure-fed-{}", Uuid::new_v4())); + std::fs::write(&path, "fixture.federated.token\n").unwrap(); + let login = WorkloadIdentityLogin::new( + TENANT, + CLIENT_ID, + &path, + replay("workload_identity_token.json"), + ) + .unwrap(); + let token = login.token(MANAGED_HSM_SCOPE).await.unwrap(); + assert_eq!(token.token(), "fixture-workload-identity-token"); + assert!(login.transport().is_done()); + + std::fs::remove_file(&path).unwrap(); + let error = login.token(MANAGED_HSM_SCOPE).await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + assert!(error.to_string().contains("cannot read")); +} + +#[tokio::test] +async fn imds_gives_a_user_assigned_identity_token() { + let login = ManagedIdentityLogin::imds(replay("imds_token.json")) + .with_client_id("test-only-user-assigned-id"); + let token = login.token(KEY_VAULT_SCOPE).await.unwrap(); + assert_eq!(token.token(), "fixture-imds-token"); + assert_eq!(token.expires_in(), Some(Duration::from_secs(86399))); + assert!(login.transport().is_done()); +} + +#[tokio::test] +async fn app_service_gives_a_token_that_expires_on_a_time() { + let login = ManagedIdentityLogin::app_service( + "http://127.0.0.1:41056/msi/token", + "fixture-identity-header", + replay("app_service_token.json"), + ) + .unwrap() + .with_clock(fixed_clock()); + let token = login.token(MANAGED_HSM_SCOPE).await.unwrap(); + assert_eq!(token.token(), "fixture-app-service-token"); + assert_eq!(token.expires_in(), Some(Duration::from_secs(3600))); + assert!(login.transport().is_done()); +} + +#[tokio::test] +async fn a_refused_secret_is_access_denied_and_says_why() { + let login = ClientSecretLogin::new( + TENANT, + CLIENT_ID, + "the-wrong-secret", + Replay::new().answer(VendorResponse::new( + 401, + br#"{"error":"invalid_client","error_description":"AADSTS7000215: Invalid client secret provided."}"#.to_vec(), + )), + ) + .unwrap(); + let error = login.token(KEY_VAULT_SCOPE).await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + assert!(error.to_string().contains("AADSTS7000215")); + assert!(!error.to_string().contains("the-wrong-secret")); +} + +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()) +} + +#[tokio::test] +async fn the_default_credentials_come_from_the_environment() { + let secret = [ + ("AZURE_TENANT_ID", TENANT), + ("AZURE_CLIENT_ID", CLIENT_ID), + ("AZURE_CLIENT_SECRET", CLIENT_SECRET), + ]; + + let credentials = DefaultCredentials::from_env_lookup( + KeyVaultEmulator::new(), + &env(&[ + ("AZURE_KEY_VAULT_ACCESS_TOKEN", "static-token"), + secret[0], + secret[1], + secret[2], + ]), + ) + .unwrap(); + assert!(matches!(credentials, DefaultCredentials::Token(_))); + assert_eq!( + credentials.token(KEY_VAULT_SCOPE).await.unwrap().token(), + "static-token" + ); + + let emulator = KeyVaultEmulator::new(); + let credentials = DefaultCredentials::from_env_lookup(emulator.clone(), &env(&secret)).unwrap(); + assert!(matches!(credentials, DefaultCredentials::ClientSecret(_))); + assert_eq!( + credentials.token(KEY_VAULT_SCOPE).await.unwrap().token(), + "fixture-exchanged-token-1" + ); + // Cached per scope. + let _ = credentials.token(KEY_VAULT_SCOPE).await.unwrap(); + assert_eq!(emulator.exchanges(), 1); + let _ = credentials.token(MANAGED_HSM_SCOPE).await.unwrap(); + assert_eq!(emulator.exchanges(), 2); + + let credentials = DefaultCredentials::from_env_lookup( + KeyVaultEmulator::new(), + &env(&[ + ("AZURE_TENANT_ID", TENANT), + ("AZURE_CLIENT_ID", CLIENT_ID), + ("AZURE_FEDERATED_TOKEN_FILE", "/var/run/secrets/token"), + ]), + ) + .unwrap(); + assert!(matches!( + credentials, + DefaultCredentials::WorkloadIdentity(_) + )); + + let error = DefaultCredentials::from_env_lookup( + KeyVaultEmulator::new(), + &env(&[ + ("AZURE_TENANT_ID", TENANT), + ("AZURE_CLIENT_ID", CLIENT_ID), + ("AZURE_CLIENT_CERTIFICATE_PATH", "/tmp/cert.pem"), + ]), + ) + .unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + assert!(error.to_string().contains("not supported")); + + let credentials = DefaultCredentials::from_env_lookup( + KeyVaultEmulator::new(), + &env(&[ + ("IDENTITY_ENDPOINT", "http://127.0.0.1:41056/msi/token"), + ("IDENTITY_HEADER", "h"), + ]), + ) + .unwrap(); + let DefaultCredentials::ManagedIdentity(login) = credentials else { + panic!("not a managed identity"); + }; + assert!(login + .inner() + .url(KEY_VAULT_SCOPE) + .starts_with("http://127.0.0.1:41056/msi/token?api-version=2019-08-01")); + + let credentials = DefaultCredentials::from_env_lookup( + KeyVaultEmulator::new(), + &env(&[("AZURE_CLIENT_ID", "user-assigned")]), + ) + .unwrap(); + let DefaultCredentials::ManagedIdentity(login) = credentials else { + panic!("not a managed identity"); + }; + assert_eq!( + login.inner().url(KEY_VAULT_SCOPE), + "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01\ + &resource=https%3A%2F%2Fvault.azure.net&client_id=user-assigned" + ); + + for bad in [ + vec![ + ("IDENTITY_ENDPOINT", "http://evil.example/token"), + ("IDENTITY_HEADER", "h"), + ], + vec![ + ("AZURE_TENANT_ID", "a/b"), + ("AZURE_CLIENT_ID", CLIENT_ID), + ("AZURE_CLIENT_SECRET", CLIENT_SECRET), + ], + vec![ + secret[0], + secret[1], + secret[2], + ("AZURE_AUTHORITY_HOST", "http://login.example"), + ], + ] { + let error = + DefaultCredentials::from_env_lookup(KeyVaultEmulator::new(), &env(&bad)).unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied, "{bad:?}"); + } +} + +#[derive(Deserialize)] +struct ErrorCase { + name: String, + response: FixtureResponse, + kind: String, +} + +#[tokio::test] +async fn every_written_error_maps_to_its_kind() { + let cases: Vec = load("errors.json"); + assert!(cases.len() >= 13); + let wrapped = rsa_wrapped(); + for case in cases { + let kms = fixture_client(Replay::new().answer(case.response.to_response())); + let error = kms + .unwrap(RSA_KEY, &wrapped, &data_aad(0)) + .await + .unwrap_err(); + let message = format!("{error:?}"); + assert!(!message.contains(TOKEN), "{message}"); + assert_eq!( + format!("{:?}", kind_of(error)), + case.kind, + "{}: {message}", + case.name + ); + } +} + +#[tokio::test] +async fn a_throttle_waits_for_retry_after_first() { + let cases: Vec = load("errors.json"); + let throttled = cases.iter().find(|case| case.name == "throttled").unwrap(); + let kms = fixture_client(Replay::new().answer(throttled.response.to_response())); + let error = kms + .unwrap(RSA_KEY, &rsa_wrapped(), &data_aad(0)) + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Throttled); + assert_eq!(kms.transport().sleeps(), [Duration::from_secs(1)]); + + // A 503 with Retry-After does not wait: the adapter does not retry it. + let unavailable = cases + .iter() + .find(|case| case.name == "service unavailable") + .unwrap(); + let kms = fixture_client(Replay::new().answer(unavailable.response.to_response())); + let _ = kms + .unwrap(RSA_KEY, &rsa_wrapped(), &data_aad(0)) + .await + .unwrap_err(); + assert!(kms.transport().sleeps().is_empty()); +} + +/// 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: Duration) {} +} + +#[tokio::test] +async fn no_response_is_unavailable() { + let error = fixture_client(Down) + .unwrap(RSA_KEY, &rsa_wrapped(), &data_aad(0)) + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Unavailable); + let error = ManagedIdentityLogin::imds(Down) + .token(KEY_VAULT_SCOPE) + .await + .unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::Unavailable); +} + +struct NoToken; + +impl TokenProvider for NoToken { + async fn token(&self, _scope: &str) -> Result { + Err(AuthError::access_denied("none configured")) + } +} + +#[tokio::test] +async fn no_token_is_access_denied_and_sends_nothing() { + let kms = AzureKeyVault::new(AzureKeyVaultConfig::new(), Replay::new(), NoToken); + let error = kms + .wrap(RSA_KEY, &Protected::new([1u8; 32]), &data_aad(0)) + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::AccessDenied); +} + +#[tokio::test] +async fn a_key_reference_that_is_not_a_key_id_is_refused_before_any_call() { + let emulator = KeyVaultEmulator::new(); + let kms = fixture_client(emulator.clone()); + for bad in [ + "", + "fixture-rsa-key", + "https://evil.example/keys/fixture-rsa-key", + "https://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key/v1/unwrapkey", + "https://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key?x=1", + "http://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key", + ] { + let error = kms + .wrap(bad, &Protected::new([1u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::NotFound, "{bad:?}"); + let error = kms.unwrap(bad, &rsa_wrapped(), 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 = KeyVaultEmulator::new(); + let kms = fixture_client(emulator.clone()); + for bad in [&b""[..], &[1, 1, 1, b'a'], &[1, 9, 1, b'a', 7], b"CiQA"] { + let error = kms.unwrap(RSA_KEY, bad, b"a").await.unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::InvalidCiphertext); + } + assert!(emulator.requests().is_empty()); +} + +fn ok_json(body: serde_json::Value) -> VendorResponse { + VendorResponse::new(200, body.to_string().into_bytes()) +} + +fn key_bundle(kid: &str, kty: &str) -> VendorResponse { + ok_json(serde_json::json!({ "key": { "kid": kid, "kty": kty } })) +} + +#[tokio::test] +async fn a_key_read_that_cannot_be_used_is_refused() { + let current = format!("{RSA_KEY}/{RSA_VERSION}"); + for (response, kind) in [ + (key_bundle(¤t, "EC"), BackendErrorKind::AccessDenied), + ( + key_bundle(&format!("{OCT_KEY}/{OCT_VERSION}"), "RSA"), + BackendErrorKind::Unavailable, + ), + (key_bundle(RSA_KEY, "RSA"), BackendErrorKind::Unavailable), + ( + ok_json(serde_json::json!({})), + BackendErrorKind::Unavailable, + ), + ( + VendorResponse::new(200, b"not json".to_vec()), + BackendErrorKind::Unavailable, + ), + ] { + let kms = fixture_client(Replay::new().answer(response)); + let error = kms + .wrap(RSA_KEY, &Protected::new([1u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!(kind_of(error), kind); + } + + // A key whose type is not the configured algorithm's. + let kms = AzureKeyVault::new( + AzureKeyVaultConfig::new().with_algorithm(WrapAlgorithm::A256Kw), + Replay::new().answer(key_bundle(¤t, "RSA")), + AccessToken::new(TOKEN), + ); + let error = kms + .wrap(RSA_KEY, &Protected::new([1u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::AccessDenied); + + // A pinned version must be the version Azure reads. + let kms = fixture_client(Replay::new().answer(key_bundle(¤t, "RSA"))); + let error = kms + .wrap( + &format!("{RSA_KEY}/{RSA_OLD_VERSION}"), + &Protected::new([1u8; 32]), + b"a", + ) + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Unavailable); +} + +#[tokio::test] +async fn a_wrap_response_that_cannot_be_trusted_is_unavailable() { + let current = format!("{RSA_KEY}/{RSA_VERSION}"); + for body in [ + serde_json::json!({ "kid": current, "value": "" }), + serde_json::json!({ "kid": current, "value": "+/+/" }), + serde_json::json!({ "kid": current }), + serde_json::json!({ "kid": RSA_KEY, "value": "AQID" }), + serde_json::json!({ "kid": format!("{RSA_KEY}/{RSA_OLD_VERSION}"), "value": "AQID" }), + serde_json::json!({ "value": "AQID" }), + ] { + let kms = fixture_client( + Replay::new() + .answer(key_bundle(¤t, "RSA")) + .answer(ok_json(body.clone())), + ); + let error = kms + .wrap(RSA_KEY, &Protected::new([1u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Unavailable, "{body}"); + } +} + +#[tokio::test] +async fn an_unwrap_response_that_cannot_be_trusted_is_refused() { + let plaintext = wrap_plaintext(&Protected::new([7u8; 32]), &data_aad(0)); + let mut padding = plaintext.to_vec(); + padding[71] = 1; + for (value, kind) in [ + (None, BackendErrorKind::Unavailable), + (Some("+/+/".to_owned()), BackendErrorKind::Unavailable), + ( + Some(Base64UrlUnpadded::encode_string(&[7u8; 32])), + BackendErrorKind::InvalidCiphertext, + ), + ( + Some(Base64UrlUnpadded::encode_string(&padding)), + BackendErrorKind::InvalidCiphertext, + ), + ( + Some(Base64UrlUnpadded::encode_string(&wrap_plaintext( + &Protected::new([7u8; 32]), + &data_aad(1), + ))), + BackendErrorKind::InvalidCiphertext, + ), + ] { + let body = match &value { + Some(value) => serde_json::json!({ "kid": RSA_KEY, "value": value }), + None => serde_json::json!({ "kid": RSA_KEY }), + }; + let kms = fixture_client(Replay::new().answer(ok_json(body))); + let error = kms + .unwrap(RSA_KEY, &rsa_wrapped(), &data_aad(0)) + .await + .unwrap_err(); + assert_eq!(kind_of(error), kind, "{value:?}"); + } + // The right plaintext, padded or not on the wire, opens. + let kms = fixture_client(Replay::new().answer(ok_json(serde_json::json!({ + "kid": RSA_KEY, + "value": format!("{}=", Base64UrlUnpadded::encode_string(&plaintext)), + })))); + let opened = kms + .unwrap(RSA_KEY, &rsa_wrapped(), &data_aad(0)) + .await + .unwrap(); + assert_eq!(opened.risky_ref(), &[7u8; 32]); +} + +#[tokio::test] +async fn a_401_drops_the_cached_token() { + let emulator = KeyVaultEmulator::new(); + let kms = AzureKeyVault::new( + AzureKeyVaultConfig::new(), + emulator.clone(), + CachedToken::new( + ClientSecretLogin::new(TENANT, CLIENT_ID, CLIENT_SECRET, emulator.clone()).unwrap(), + ), + ); + let wrapped = kms + .wrap(RSA_KEY, &Protected::new([2u8; 32]), b"a") + .await + .unwrap(); + assert_eq!(emulator.exchanges(), 1); + emulator.revoke_tokens(); + let error = kms.unwrap(RSA_KEY, &wrapped, b"a").await.unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::AccessDenied); + // The next call gets a new token. + let opened = kms.unwrap(RSA_KEY, &wrapped, b"a").await.unwrap(); + assert_eq!(opened.risky_ref(), &[2u8; 32]); + assert_eq!(emulator.exchanges(), 2); +} + +#[tokio::test] +async fn the_backend_reports_azure_and_sleeps_on_its_transport() { + let emulator = KeyVaultEmulator::new(); + let kms = fixture_client(emulator.clone()); + assert_eq!(kms.vendor(), KeyVendor::Azure); + kms.sleep(Duration::from_millis(50)).await; + assert_eq!(emulator.sleeps(), [Duration::from_millis(50)]); +} diff --git a/packages/stack-kms-azure/tests/fixtures/app_service_token.json b/packages/stack-kms-azure/tests/fixtures/app_service_token.json new file mode 100644 index 0000000000..151fb03cc3 --- /dev/null +++ b/packages/stack-kms-azure/tests/fixtures/app_service_token.json @@ -0,0 +1,31 @@ +[ + { + "request": { + "method": "GET", + "url": "http://127.0.0.1:41056/msi/token?api-version=2019-08-01&resource=https%3A%2F%2Fmanagedhsm.azure.net", + "headers": [ + [ + "x-identity-header", + "fixture-identity-header" + ] + ], + "body": null + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=utf-8" + ] + ], + "body": { + "access_token": "fixture-app-service-token", + "expires_on": "1793883600", + "resource": "https://managedhsm.azure.net", + "token_type": "Bearer", + "client_id": "test-only-system-assigned-id" + } + } + } +] diff --git a/packages/stack-kms-azure/tests/fixtures/client_secret_token.json b/packages/stack-kms-azure/tests/fixtures/client_secret_token.json new file mode 100644 index 0000000000..ebd61b3797 --- /dev/null +++ b/packages/stack-kms-azure/tests/fixtures/client_secret_token.json @@ -0,0 +1,30 @@ +[ + { + "request": { + "method": "POST", + "url": "https://login.microsoftonline.com/test-only-tenant/oauth2/v2.0/token", + "headers": [ + [ + "content-type", + "application/x-www-form-urlencoded" + ] + ], + "body": "grant_type=client_credentials&client_id=test-only-client-id&scope=https%3A%2F%2Fvault.azure.net%2F.default&client_secret=fixture-client-secret" + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=utf-8" + ] + ], + "body": { + "token_type": "Bearer", + "expires_in": 3599, + "ext_expires_in": 3599, + "access_token": "fixture-client-secret-token" + } + } + } +] diff --git a/packages/stack-kms-azure/tests/fixtures/errors.json b/packages/stack-kms-azure/tests/fixtures/errors.json new file mode 100644 index 0000000000..3b7f365083 --- /dev/null +++ b/packages/stack-kms-azure/tests/fixtures/errors.json @@ -0,0 +1,167 @@ +[ + { + "name": "an access policy without unwrapKey", + "kind": "AccessDenied", + "response": { + "status": 403, + "headers": [["content-type", "application/json; charset=utf-8"]], + "body": { + "error": { + "code": "Forbidden", + "message": "The user, group or application 'appid=test-only-client-id;oid=00000000-0000-0000-0000-000000000000;iss=https://sts.windows.net/test-only-tenant/' does not have keys unwrapKey permission on key vault 'cipherstash-fixture;location=eastus'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287", + "innererror": { "code": "AccessDenied" } + } + } + } + }, + { + "name": "an RBAC role without unwrapKey", + "kind": "AccessDenied", + "response": { + "status": 403, + "headers": [["content-type", "application/json; charset=utf-8"]], + "body": { + "error": { + "code": "Forbidden", + "message": "Caller is not authorized to perform action on resource. Action: 'Microsoft.KeyVault/vaults/keys/unwrap/action'", + "innererror": { "code": "ForbiddenByRbac" } + } + } + } + }, + { + "name": "a disabled key version", + "kind": "Unavailable", + "response": { + "status": 403, + "headers": [["content-type", "application/json; charset=utf-8"]], + "body": { + "error": { + "code": "Forbidden", + "message": "Operation \"unwrapKey\" is not allowed on a disabled key.", + "innererror": { "code": "KeyDisabled" } + } + } + } + }, + { + "name": "a token for the wrong audience", + "kind": "AccessDenied", + "response": { + "status": 401, + "headers": [["content-type", "application/json; charset=utf-8"]], + "body": { + "error": { + "code": "Unauthorized", + "message": "AKV10022: Invalid audience. Expected https://vault.azure.net, found: https://managedhsm.azure.net." + } + } + } + }, + { + "name": "a 401 challenge with no body", + "kind": "AccessDenied", + "response": { + "status": 401, + "headers": [["www-authenticate", "Bearer authorization=\"https://login.microsoftonline.com/test-only-tenant\", resource=\"https://vault.azure.net\""]], + "body": null + } + }, + { + "name": "a key that does not exist", + "kind": "NotFound", + "response": { + "status": 404, + "headers": [["content-type", "application/json; charset=utf-8"]], + "body": { + "error": { + "code": "KeyNotFound", + "message": "A key with (name/id) fixture-rsa-key/1a2b3c4d5e6f708192a3b4c5d6e7f801 was not found in this key vault. If you recently deleted this key you may be able to recover it using the correct recovery command. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125182" + } + } + } + }, + { + "name": "a Managed HSM resource that does not exist", + "kind": "NotFound", + "response": { + "status": 404, + "headers": [["content-type", "application/json; charset=utf-8"]], + "body": { "error": { "code": "NotFound", "message": "The requested resource was not found." } } + } + }, + { + "name": "throttled", + "kind": "Throttled", + "response": { + "status": 429, + "headers": [["content-type", "application/json; charset=utf-8"], ["retry-after", "1"]], + "body": { + "error": { + "code": "Throttled", + "message": "Request was not processed because too many requests were received. Reason: A generic reason for the throttling" + } + } + } + }, + { + "name": "a ciphertext that does not unwrap", + "kind": "InvalidCiphertext", + "response": { + "status": 400, + "headers": [["content-type", "application/json; charset=utf-8"]], + "body": { "error": { "code": "BadParameter", "message": "Error occurred while decrypting the value." } } + } + }, + { + "name": "an expired key", + "kind": "Unavailable", + "response": { + "status": 403, + "headers": [["content-type", "application/json; charset=utf-8"]], + "body": { + "error": { + "code": "Forbidden", + "message": "Operation \"unwrapKey\" is not allowed on an expired key.", + "innererror": { "code": "KeyExpired" } + } + } + } + }, + { + "name": "another refused request", + "kind": "AccessDenied", + "response": { + "status": 400, + "headers": [["content-type", "application/json; charset=utf-8"]], + "body": { "error": { "code": "BadRequest", "message": "The request is not valid." } } + } + }, + { + "name": "an internal error", + "kind": "Unavailable", + "response": { + "status": 500, + "headers": [["content-type", "application/json; charset=utf-8"]], + "body": { "error": { "code": "InternalServerError", "message": "An unexpected error occurred." } } + } + }, + { + "name": "service unavailable", + "kind": "Unavailable", + "response": { + "status": 503, + "headers": [["content-type", "application/json; charset=utf-8"], ["retry-after", "5"]], + "body": { "error": { "code": "ServiceUnavailable", "message": "The service is unavailable." } } + } + }, + { + "name": "a gateway error page", + "kind": "Unavailable", + "response": { + "status": 502, + "headers": [["content-type", "text/html"]], + "body": "502 Bad Gateway" + } + } +] diff --git a/packages/stack-kms-azure/tests/fixtures/hsm_unwrap.json b/packages/stack-kms-azure/tests/fixtures/hsm_unwrap.json new file mode 100644 index 0000000000..87580a2de3 --- /dev/null +++ b/packages/stack-kms-azure/tests/fixtures/hsm_unwrap.json @@ -0,0 +1,35 @@ +[ + { + "request": { + "method": "POST", + "url": "https://cipherstash-fixture.managedhsm.azure.net/keys/fixture-oct-key/5c6d7e8f90a1b2c3d4e5f60718293a4b/unwrapkey?api-version=7.4", + "headers": [ + [ + "authorization", + "Bearer fixture-access-token" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "alg": "A256KW", + "value": "s5wOZ2cIlWb7bUVAE7HwqbCTTboLbpomSx3_UeMOCvRTzt73MnR26WfnuBScdWW3KVvUYk34vxVccv1JdV6jSu1nN6eGxLYI75FEuhac3Ys" + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=utf-8" + ] + ], + "body": { + "kid": "https://cipherstash-fixture.managedhsm.azure.net/keys/fixture-oct-key/5c6d7e8f90a1b2c3d4e5f60718293a4b", + "value": "AQptRY4y1W5Fu7bK_g_ouGE_DxuPhM6S0DRTtUI2Ygc1REREREREREREREREREREREREREREREREREREREREREQAAAAAAAAA" + } + } + } +] diff --git a/packages/stack-kms-azure/tests/fixtures/hsm_wrap.json b/packages/stack-kms-azure/tests/fixtures/hsm_wrap.json new file mode 100644 index 0000000000..9f2a240907 --- /dev/null +++ b/packages/stack-kms-azure/tests/fixtures/hsm_wrap.json @@ -0,0 +1,78 @@ +[ + { + "request": { + "method": "GET", + "url": "https://cipherstash-fixture.managedhsm.azure.net/keys/fixture-oct-key?api-version=7.4", + "headers": [ + [ + "authorization", + "Bearer fixture-access-token" + ] + ], + "body": null + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=utf-8" + ] + ], + "body": { + "key": { + "kid": "https://cipherstash-fixture.managedhsm.azure.net/keys/fixture-oct-key/5c6d7e8f90a1b2c3d4e5f60718293a4b", + "kty": "oct-HSM", + "key_ops": [ + "wrapKey", + "unwrapKey", + "encrypt", + "decrypt" + ] + }, + "attributes": { + "enabled": true, + "created": 1793793600, + "updated": 1793793600, + "recoveryLevel": "Recoverable+Purgeable", + "recoverableDays": 90, + "exportable": false + }, + "release_policy": null + } + } + }, + { + "request": { + "method": "POST", + "url": "https://cipherstash-fixture.managedhsm.azure.net/keys/fixture-oct-key/5c6d7e8f90a1b2c3d4e5f60718293a4b/wrapkey?api-version=7.4", + "headers": [ + [ + "authorization", + "Bearer fixture-access-token" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "alg": "A256KW", + "value": "AQptRY4y1W5Fu7bK_g_ouGE_DxuPhM6S0DRTtUI2Ygc1REREREREREREREREREREREREREREREREREREREREREQAAAAAAAAA" + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=utf-8" + ] + ], + "body": { + "kid": "https://cipherstash-fixture.managedhsm.azure.net/keys/fixture-oct-key/5c6d7e8f90a1b2c3d4e5f60718293a4b", + "value": "s5wOZ2cIlWb7bUVAE7HwqbCTTboLbpomSx3_UeMOCvRTzt73MnR26WfnuBScdWW3KVvUYk34vxVccv1JdV6jSu1nN6eGxLYI75FEuhac3Ys" + } + } + } +] diff --git a/packages/stack-kms-azure/tests/fixtures/imds_token.json b/packages/stack-kms-azure/tests/fixtures/imds_token.json new file mode 100644 index 0000000000..c45d1638c6 --- /dev/null +++ b/packages/stack-kms-azure/tests/fixtures/imds_token.json @@ -0,0 +1,34 @@ +[ + { + "request": { + "method": "GET", + "url": "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fvault.azure.net&client_id=test-only-user-assigned-id", + "headers": [ + [ + "metadata", + "true" + ] + ], + "body": null + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=utf-8" + ] + ], + "body": { + "access_token": "fixture-imds-token", + "client_id": "test-only-user-assigned-id", + "expires_in": "86399", + "expires_on": "1793966399", + "ext_expires_in": "86399", + "not_before": "1793880000", + "resource": "https://vault.azure.net", + "token_type": "Bearer" + } + } + } +] diff --git a/packages/stack-kms-azure/tests/fixtures/test_only_rsa_key.pkcs8.b64 b/packages/stack-kms-azure/tests/fixtures/test_only_rsa_key.pkcs8.b64 new file mode 100644 index 0000000000..c86c7ee745 --- /dev/null +++ b/packages/stack-kms-azure/tests/fixtures/test_only_rsa_key.pkcs8.b64 @@ -0,0 +1,22 @@ +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDK5SgDqGCCorYMpB7z6I+6nxXN +U6plIhXmB9+nUGD/+YFAM5Q0RjfD7vWE25Fe8y6GhoeVm10+K+KOJxbF6lNYtmXJ+nfMOMiAkXpi +xJA7nGKUfC/tSWmRQLZko3O5/hjf1gl+BwQCCRUZK8EFc5ZZUL2dx3s6JW9PF84VRkl4C4LZ6sOh +SinKMXOb0URZUrZv4oT2NWxG7Ssoz8vJe/qxtiiNinAa5cHNd/Ji6W0vOdu6QGTyI4DGk3zdzg5G +PvTuODyaQ8+3yno00Bv76wkqhlLRD4lD8oQQRHIpcYO21c5gi+VDKICUIzq0m+QBHCqtvCSb6bYj +41NC7KLYc9wrAgMBAAECggEAVuIKYIaZzVcf3gnpt0i/Q4scKvDI5sXR2s9EVsF0yChv0K5FqJzp +BF4vhNUBS0eXhMPXC5mcNCNWwMi8V8QgLLzuma9H3YvzUBr77Y9+tAgjrrH/U/0715d+g1n7FIpa +3H8rUWxvqu94uW6jGVKF73lDuWzSt/OP1q668SFIFL8V825MSvmR8Eaz+3Ezc0CAOe+SDJEOdxH/ +QbRIs+RkAYpsTjS3ZY5lsO7CIqKZgHsPPmBEeAVszK3I3jkm9RJb5LqM/66+taGyM4ThqJVC5m1+ +aY9Kb6edZkf80u7tIu23QXs3w4NVcx+8IPSfWV6fOn4GzJUKpJ+ThjuAznguAQKBgQDnXEpScDJW +yCg8Gw9udSI10f+ciu8GJrtMngh6807A7grE83VU4Z4ewp4eMJ9Znz6RCrnNxxjqULe1f9NoKvNo +BDlYZ6TUfeOXdXayDkzMORRFWVlRPtvgRwtqp4x4LJbI+YfgQ0msUGkmuklAgJDKBXa3jBFDTlsW +Mpl//uBiGQKBgQDggMyFr7nb95g2BhwwogmAYCaFNPPV2dd3nLLJnEKbykBtApMoaWQkEfZC9h0o +jSq6i3rjbOvk0UYglIv4Szp3Gj89krJC/qRClyXYb+Pl/rk7q4u11xhFqoYQT5R6l7iBnW7rCBal +pZ0KVrQhxN5Yi/tFRCJhYK1hymws+b/g4wKBgCLkjGD6gt0LuuW1vxINoGc1kLNvULVAyia3/tds +MohuXjlt7lgq3soM2AP9mUrO9YSydWtPh7ZvDgXVjXC8aZFacbufhgB2LAj/sjMQbQyv3xrGau4R +6N9eV55yPoExWFxfJdrwyv+t8O3iUoHj4fJV+J2eBkx8WMh0QSpIqlahAoGAdKX3nq7Dn2tyhv99 +vjczQKS03xm4aDEcJ2Yf72llopTgDMP9uk2oXqWGkxxV4jAXN34ph+mcV/P0wBDu+T/9TM0ydse2 +H+E3b/IhE+hvPZ2wmTR1DzKHxgroRalTuK8EizRF4ZD+U2tNwYVzauhjrxZspHzWcEpPTDRlKKlL +7YkCgYAhll5tqi/dNBGSOi1ipJLbUqQywkfFxxc0RkBLmUMhgjyQVNvcnAHl5cwt9zL5bxDOLO7e +iRQmMb/rq4+OD6q9Pf54f7EP2kBpB+fsERR+vwS0Qrgcn7sQ3POI0jyKwVj01C5uZNSmLs0vM5cu +L1DOuSEC7XBACmG3FfNCZQ5uKQ== diff --git a/packages/stack-kms-azure/tests/fixtures/vault_unwrap.json b/packages/stack-kms-azure/tests/fixtures/vault_unwrap.json new file mode 100644 index 0000000000..addc028afb --- /dev/null +++ b/packages/stack-kms-azure/tests/fixtures/vault_unwrap.json @@ -0,0 +1,35 @@ +[ + { + "request": { + "method": "POST", + "url": "https://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key/1a2b3c4d5e6f708192a3b4c5d6e7f801/unwrapkey?api-version=7.4", + "headers": [ + [ + "authorization", + "Bearer fixture-access-token" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "alg": "RSA-OAEP-256", + "value": "ORTAKFanjzFF7Yxe8SZ2v1djb_U_bM84HJCwCcrQqxeCBJ8gtXQ-TWGFgZW_4rhQYsgb7V-KPg1tjwHULVG9sOAJn6VVKx3P1W1mnSM0Q3iWb7ZdOxQG7vxpg7pxqE05j-pjopuiRVCIzVjIhL00VLMqh42xeGsemH0y5ev3z4yYjmq3hXPp__alB6dyLECrVR3p61Qf67DH5JU42qbiR0U6sgvkPLyTveCVmqR3C8CMuAGJ5hkwiEMLyBjbafvFchEViVqecFRrEw4NY_eZjdoxzHGCfMelxvaU9BDUb3MIJjKlRjQZdfVz56RouSoA6xF2EfjE8brcnsJUnMm89A" + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=utf-8" + ] + ], + "body": { + "kid": "https://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key/1a2b3c4d5e6f708192a3b4c5d6e7f801", + "value": "AZCROjkfJsyq0AUBFvSLVnj8ah81zeY_Fgo8WYD19ahDQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkIAAAAAAAAA" + } + } + } +] diff --git a/packages/stack-kms-azure/tests/fixtures/vault_unwrap_pinned.json b/packages/stack-kms-azure/tests/fixtures/vault_unwrap_pinned.json new file mode 100644 index 0000000000..dca8c2e0c1 --- /dev/null +++ b/packages/stack-kms-azure/tests/fixtures/vault_unwrap_pinned.json @@ -0,0 +1,35 @@ +[ + { + "request": { + "method": "POST", + "url": "https://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key/0a1b2c3d4e5f60718293a4b5c6d7e8f9/unwrapkey?api-version=7.4", + "headers": [ + [ + "authorization", + "Bearer fixture-access-token" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "alg": "RSA-OAEP-256", + "value": "DAWKHW2AUmqhtaN0bH02yHGNgt-5_R5TCo9TO_WjXnE30R-TB31R109oRmec9ImjDh6oVXN3fz3__gJI9NN0f9k4Sy3PS2NMmHDPOKDitGFoeXp44Gudm_hlSKMKAhAjweWxd5G6g2Dd4OjHhWm1MlSEZS4RaBcRbY4hQIYXJcbrq2K5lXYW7cQmlrTmGuJ_4Lp3pfBlSrrkqcsIkmt3b_8Qw561q0yMnAuCmZmVKOBW7YfV2fGSmVs7CyjOZNjDREhZjsAKTY986_fgU8wZZJ6XaViMFpfIVPljWqhudqQ1nDBu_luxmGVyjOhAnJqVfX9R2i8VUK-gOpumrlDnJw" + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=utf-8" + ] + ], + "body": { + "kid": "https://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key/0a1b2c3d4e5f60718293a4b5c6d7e8f9", + "value": "AQ9GLasPvoFRUzYY5ESTCHLnjARVXWuAUsA-zUcJL6ZpQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0MAAAAAAAAA" + } + } + } +] diff --git a/packages/stack-kms-azure/tests/fixtures/vault_wrap.json b/packages/stack-kms-azure/tests/fixtures/vault_wrap.json new file mode 100644 index 0000000000..d14a0f5c33 --- /dev/null +++ b/packages/stack-kms-azure/tests/fixtures/vault_wrap.json @@ -0,0 +1,80 @@ +[ + { + "request": { + "method": "GET", + "url": "https://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key?api-version=7.4", + "headers": [ + [ + "authorization", + "Bearer fixture-access-token" + ] + ], + "body": null + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=utf-8" + ] + ], + "body": { + "key": { + "kid": "https://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key/1a2b3c4d5e6f708192a3b4c5d6e7f801", + "kty": "RSA", + "key_ops": [ + "encrypt", + "decrypt", + "wrapKey", + "unwrapKey" + ], + "n": "yuUoA6hggqK2DKQe8-iPup8VzVOqZSIV5gffp1Bg__mBQDOUNEY3w-71hNuRXvMuhoaHlZtdPivijicWxepTWLZlyfp3zDjIgJF6YsSQO5xilHwv7UlpkUC2ZKNzuf4Y39YJfgcEAgkVGSvBBXOWWVC9ncd7OiVvTxfOFUZJeAuC2erDoUopyjFzm9FEWVK2b-KE9jVsRu0rKM_LyXv6sbYojYpwGuXBzXfyYultLznbukBk8iOAxpN83c4ORj707jg8mkPPt8p6NNAb--sJKoZS0Q-JQ_KEEERyKXGDttXOYIvlQyiAlCM6tJvkARwqrbwkm-m2I-NTQuyi2HPcKw", + "e": "AQAB" + }, + "attributes": { + "enabled": true, + "created": 1793793600, + "updated": 1793793600, + "recoveryLevel": "Recoverable+Purgeable", + "recoverableDays": 90, + "exportable": false + }, + "tags": {} + } + } + }, + { + "request": { + "method": "POST", + "url": "https://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key/1a2b3c4d5e6f708192a3b4c5d6e7f801/wrapkey?api-version=7.4", + "headers": [ + [ + "authorization", + "Bearer fixture-access-token" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "alg": "RSA-OAEP-256", + "value": "AZCROjkfJsyq0AUBFvSLVnj8ah81zeY_Fgo8WYD19ahDQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkIAAAAAAAAA" + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=utf-8" + ] + ], + "body": { + "kid": "https://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key/1a2b3c4d5e6f708192a3b4c5d6e7f801", + "value": "ORTAKFanjzFF7Yxe8SZ2v1djb_U_bM84HJCwCcrQqxeCBJ8gtXQ-TWGFgZW_4rhQYsgb7V-KPg1tjwHULVG9sOAJn6VVKx3P1W1mnSM0Q3iWb7ZdOxQG7vxpg7pxqE05j-pjopuiRVCIzVjIhL00VLMqh42xeGsemH0y5ev3z4yYjmq3hXPp__alB6dyLECrVR3p61Qf67DH5JU42qbiR0U6sgvkPLyTveCVmqR3C8CMuAGJ5hkwiEMLyBjbafvFchEViVqecFRrEw4NY_eZjdoxzHGCfMelxvaU9BDUb3MIJjKlRjQZdfVz56RouSoA6xF2EfjE8brcnsJUnMm89A" + } + } + } +] diff --git a/packages/stack-kms-azure/tests/fixtures/vault_wrap_pinned.json b/packages/stack-kms-azure/tests/fixtures/vault_wrap_pinned.json new file mode 100644 index 0000000000..38edb2caa4 --- /dev/null +++ b/packages/stack-kms-azure/tests/fixtures/vault_wrap_pinned.json @@ -0,0 +1,35 @@ +[ + { + "request": { + "method": "POST", + "url": "https://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key/0a1b2c3d4e5f60718293a4b5c6d7e8f9/wrapkey?api-version=7.4", + "headers": [ + [ + "authorization", + "Bearer fixture-access-token" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "alg": "RSA-OAEP-256", + "value": "AQ9GLasPvoFRUzYY5ESTCHLnjARVXWuAUsA-zUcJL6ZpQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0MAAAAAAAAA" + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=utf-8" + ] + ], + "body": { + "kid": "https://cipherstash-fixture.vault.azure.net/keys/fixture-rsa-key/0a1b2c3d4e5f60718293a4b5c6d7e8f9", + "value": "DAWKHW2AUmqhtaN0bH02yHGNgt-5_R5TCo9TO_WjXnE30R-TB31R109oRmec9ImjDh6oVXN3fz3__gJI9NN0f9k4Sy3PS2NMmHDPOKDitGFoeXp44Gudm_hlSKMKAhAjweWxd5G6g2Dd4OjHhWm1MlSEZS4RaBcRbY4hQIYXJcbrq2K5lXYW7cQmlrTmGuJ_4Lp3pfBlSrrkqcsIkmt3b_8Qw561q0yMnAuCmZmVKOBW7YfV2fGSmVs7CyjOZNjDREhZjsAKTY986_fgU8wZZJ6XaViMFpfIVPljWqhudqQ1nDBu_luxmGVyjOhAnJqVfX9R2i8VUK-gOpumrlDnJw" + } + } + } +] diff --git a/packages/stack-kms-azure/tests/fixtures/workload_identity_token.json b/packages/stack-kms-azure/tests/fixtures/workload_identity_token.json new file mode 100644 index 0000000000..9d639a37e1 --- /dev/null +++ b/packages/stack-kms-azure/tests/fixtures/workload_identity_token.json @@ -0,0 +1,30 @@ +[ + { + "request": { + "method": "POST", + "url": "https://login.microsoftonline.com/test-only-tenant/oauth2/v2.0/token", + "headers": [ + [ + "content-type", + "application/x-www-form-urlencoded" + ] + ], + "body": "grant_type=client_credentials&client_id=test-only-client-id&scope=https%3A%2F%2Fmanagedhsm.azure.net%2F.default&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_assertion=fixture.federated.token" + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=utf-8" + ] + ], + "body": { + "token_type": "Bearer", + "expires_in": 3599, + "ext_expires_in": 3599, + "access_token": "fixture-workload-identity-token" + } + } + } +] diff --git a/packages/stack-kms-azure/tests/live.rs b/packages/stack-kms-azure/tests/live.rs new file mode 100644 index 0000000000..9d0cf052db --- /dev/null +++ b/packages/stack-kms-azure/tests/live.rs @@ -0,0 +1,119 @@ +//! The Azure backend against real Azure Key Vault and Managed HSM. +//! +//! Microsoft publishes no Key Vault emulator, so this is the only test of +//! the real service. It runs when `STACK_KMS_AZURE_LIVE_KEY` names a Key +//! Vault RSA key (`https://{vault}.vault.azure.net/keys/{name}`) and skips +//! itself otherwise. `STACK_KMS_AZURE_LIVE_HSM_KEY`, if set, names a +//! Managed HSM `oct-HSM` key that is tested the same way. The credentials +//! come from the environment +//! ([`DefaultCredentials`](stack_kms_azure::DefaultCredentials)): normally +//! `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and `AZURE_CLIENT_SECRET` of a +//! service principal with "Key Vault Crypto Service Encryption User" on the +//! key. +//! +//! ```text +//! STACK_KMS_AZURE_LIVE_KEY=https://.vault.azure.net/keys/stack-kms-azure-live \ +//! AZURE_TENANT_ID=... AZURE_CLIENT_ID=... AZURE_CLIENT_SECRET=... \ +//! mise run test:live:stack-kms-azure +//! ``` +//! +//! The opt-in `integration-azure-key-vault.yml` workflow runs it in CI. + +use stack_kms::{BackendErrorKind, Error, KeyMode, KeyVendor}; +use stack_kms_azure::{wrapped_key_version, AzureKeyVault, KeyId}; +use stack_kms_vendor::derive::record_key_aad; +use stack_kms_vendor::{contract, KeyVendorBackend}; +use uuid::Uuid; +use vitaminc::protected::{Controlled, Protected}; + +fn live_key(variable: &str) -> Option { + let key = std::env::var(variable).ok().filter(|key| !key.is_empty())?; + assert!( + KeyId::parse(&key).is_some(), + "{variable} is not an Azure key identifier: {key}" + ); + Some(key) +} + +fn live_keys() -> Vec { + ["STACK_KMS_AZURE_LIVE_KEY", "STACK_KMS_AZURE_LIVE_HSM_KEY"] + .into_iter() + .filter_map(live_key) + .collect() +} + +fn kind_of(error: Error) -> BackendErrorKind { + match error { + Error::Backend { backend, kind, .. } => { + assert_eq!(backend.name(), "azure-key-vault"); + kind + } + other => panic!("not a backend error: {other:?}"), + } +} + +#[tokio::test] +async fn azure_key_vault_meets_the_contract() { + let keys = live_keys(); + if keys.is_empty() { + eprintln!("skipped: set STACK_KMS_AZURE_LIVE_KEY to run against Azure Key Vault"); + return; + } + for key in keys { + contract::run(|| AzureKeyVault::from_env().expect("credentials"), &key).await; + } +} + +#[tokio::test] +async fn azure_key_vault_binds_the_associated_data_and_maps_errors() { + let keys = live_keys(); + if keys.is_empty() { + eprintln!("skipped: set STACK_KMS_AZURE_LIVE_KEY to run against Azure Key Vault"); + return; + } + for key in keys { + let kms = AzureKeyVault::from_env().expect("credentials"); + let id = Uuid::new_v4(); + let aad = record_key_aad(id, KeyVendor::Azure, KeyMode::Derived); + let wrapped = kms + .wrap(&key, &Protected::new([0x5Au8; 32]), &aad) + .await + .unwrap(); + assert!(wrapped_key_version(&wrapped).is_some()); + let opened = kms.unwrap(&key, &wrapped, &aad).await.unwrap(); + assert_eq!(opened.risky_ref(), &[0x5Au8; 32]); + + // Other associated data (the root key of another keyset): Azure + // unwraps, the client refuses. + let other = record_key_aad(Uuid::new_v4(), KeyVendor::Azure, KeyMode::Derived); + let error = kms.unwrap(&key, &wrapped, &other).await.unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::InvalidCiphertext); + + // A damaged ciphertext. + let mut damaged = wrapped.clone(); + let last = damaged.len() - 1; + damaged[last] ^= 0x01; + let error = kms.unwrap(&key, &damaged, &aad).await.unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::InvalidCiphertext); + + // A key that does not exist. + let parsed = KeyId::parse(&key).unwrap(); + let missing = format!( + "{}/keys/stack-kms-azure-no-such-key-{}", + parsed.vault(), + Uuid::new_v4().simple() + ); + let error = kms + .wrap(&missing, &Protected::new([1u8; 32]), &aad) + .await + .unwrap_err(); + let kind = kind_of(error); + assert!( + matches!( + kind, + BackendErrorKind::NotFound | BackendErrorKind::AccessDenied + ), + "{kind:?}" + ); + } +} diff --git a/release-plz.toml b/release-plz.toml index f5703bbd7e..a32d07fcf7 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -19,7 +19,8 @@ # feature), so a crates.io release of `eql-bindings` needs the matching # `stack-encrypt` published first. # -# Four more crates are publishable but STAGED: `stack-kms-vendor`, `stack-kms-aws`, `stack-kms-vault` and `stack-kms-gcp`, the `stack-kms-vendor` version group (the key vendor seam +# Five more crates are publishable but STAGED: `stack-kms-vendor`, +# `stack-kms-aws`, `stack-kms-vault`, `stack-kms-gcp` and `stack-kms-azure`, 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 @@ -114,3 +115,10 @@ name = "stack-kms-gcp" version_group = "stack-kms-vendor" release = false publish = false + +# Staged: see the header. +[[package]] +name = "stack-kms-azure" +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 5d952df2fd..beadbfc73e 100644 --- a/scripts/__tests__/cargo-publish-opt-out.test.mjs +++ b/scripts/__tests__/cargo-publish-opt-out.test.mjs @@ -57,8 +57,8 @@ 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, stack-kms-aws, stack-kms-vault and stack-kms-gcp - // are publishable but staged: release-plz.toml keeps them + // the Go guests' internal ABI and stays unpublished. stack-kms-vendor, + // stack-kms-aws, stack-kms-vault, stack-kms-gcp and stack-kms-azure 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). root: '.', @@ -72,6 +72,7 @@ const WORKSPACES = [ 'packages/stack-kms-aws', 'packages/stack-kms-vault', 'packages/stack-kms-gcp', + 'packages/stack-kms-azure', ]), expects: 'packages/stack-auth', }, diff --git a/scripts/__tests__/crates-ci.test.mjs b/scripts/__tests__/crates-ci.test.mjs index 576892d635..0c19c8f5f4 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 nine crate `tasks.toml` files the + * 1. Every mise task the set defines — the ten 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 nine crate task files from the root mise.toml', () => { + it('reads the ten 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-azure/tasks.toml', 'packages/stack-kms-gcp/tasks.toml', 'packages/stack-kms-vault/tasks.toml', 'packages/stack-kms-vendor/tasks.toml', @@ -342,6 +343,7 @@ describe('the scan sees every task mise sees', () => { 'doc:stack-guest-abi', 'doc:stack-kms', 'doc:stack-kms-aws', + 'doc:stack-kms-azure', 'doc:stack-kms-gcp', 'doc:stack-kms-vault', 'doc:stack-kms-vendor', @@ -401,6 +403,7 @@ describe('every task the set defines is reached by a root workflow', () => { const SOLE_CALLER_WORKFLOWS = [ `${WORKFLOW_DIR}/crap-crates.yml`, // crap:* `${WORKFLOW_DIR}/fuzz.yml`, // fuzz:* + `${WORKFLOW_DIR}/integration-azure-key-vault.yml`, // test:live:stack-kms-azure `${WORKFLOW_DIR}/integration-gcp-kms.yml`, // test:live:stack-kms-gcp `${WORKFLOW_DIR}/miri.yml`, // miri:stack-guest-abi `${WORKFLOW_DIR}/tests-crates.yml`, // doc, test:doc, test:integration:* diff --git a/scripts/__tests__/release-plz-root-config.test.mjs b/scripts/__tests__/release-plz-root-config.test.mjs index 1594b3d793..7b026ea73a 100644 --- a/scripts/__tests__/release-plz-root-config.test.mjs +++ b/scripts/__tests__/release-plz-root-config.test.mjs @@ -48,6 +48,7 @@ const STAGED = { 'stack-kms-aws': 'stack-kms-vendor', 'stack-kms-vault': 'stack-kms-vendor', 'stack-kms-gcp': 'stack-kms-vendor', + 'stack-kms-azure': 'stack-kms-vendor', } const PUBLISHABLE = [...PUBLISHED, ...Object.keys(STAGED)].sort() diff --git a/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs b/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs index 1e5e247b9e..f26af0e9b1 100644 --- a/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs +++ b/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs @@ -71,6 +71,8 @@ const EXPECTED_DISPATCHABLE = [ // Opt-in by the STACK_KMS_GCP_LIVE_KEY repository variable; dispatch runs it // against a branch the path filter does not select. '.github/workflows/integration-gcp-kms.yml', + // Opt-in by the STACK_KMS_AZURE_LIVE_KEY repository variable, as above. + '.github/workflows/integration-azure-key-vault.yml', // Path-filtered to the release machinery, so dispatch is how it gets run // against a branch that changed something the filter does not name. '.github/workflows/lint-release.yml', @@ -123,6 +125,8 @@ const EXPECTED_FORK_GUARDED_JOBS = [ '.github/workflows/claude-review.yml / review', // The routine token is a repository secret, which forks are not given. '.github/workflows/code-review.yml / request', + // A repository secret (the Azure service principal's client secret). + '.github/workflows/integration-azure-key-vault.yml / live', '.github/workflows/integration-drizzle.yml / integration', // A repository secret (the Cloud KMS service account key). '.github/workflows/integration-gcp-kms.yml / live', @@ -228,6 +232,9 @@ const PERMISSIVE_VARS = { // integration-gcp-kms.yml's opt-in: the crypto key name of the live test. STACK_KMS_GCP_LIVE_KEY: 'projects/p/locations/global/keyRings/r/cryptoKeys/stack-kms-gcp-live', + // integration-azure-key-vault.yml's opt-in: the key identifier of the live test. + STACK_KMS_AZURE_LIVE_KEY: + 'https://stack-kms-live.vault.azure.net/keys/stack-kms-azure-live', } /**