diff --git a/.github/workflows/_test-kms-live.yml b/.github/workflows/_test-kms-live.yml new file mode 100644 index 000000000..e74a1aa98 --- /dev/null +++ b/.github/workflows/_test-kms-live.yml @@ -0,0 +1,149 @@ +name: Key vendor backend live test + +# The one job behind the opt-in live workflows of the key vendor backends +# that have no emulator: integration-gcp-kms.yml (Google Cloud KMS) and +# integration-azure-key-vault.yml (Azure Key Vault). Each caller keeps its +# triggers, its opt-in and fork-PR condition on the calling job, and the +# names of its repository variables and secrets. This file holds what was +# the same in both: the secret pre-flight, the toolchain, and the +# `mise run test:live:` step. +# +# A caller gives: +# +# * `crate`: the crate. The test task is `test:live:`. +# * `name`: the name of the test step. +# * `live-key-variable`: the repository variable that turns the caller on, +# for the pre-flight message. +# * `required-secrets`: one line for each secret the test needs: +# ` `. The pre-flight fails +# the job when one of them is empty: a credentialed job that skips for +# want of a secret reads exactly like one that passes. +# * `live-env`: `NAME=value` lines (repository variables, not secrets) +# that the test reads. +# * the secrets, by the names below. `credentials-json` goes to a file +# only this user can read, outside the checkout, and its path goes in +# the variable `credentials-file-env` names. The `azure-*` secrets reach +# only the test step's environment. + +on: + workflow_call: + inputs: + crate: + type: string + required: true + name: + type: string + required: true + live-key-variable: + type: string + required: true + required-secrets: + type: string + required: true + live-env: + type: string + required: true + credentials-file-env: + type: string + default: '' + secrets: + credentials-json: + required: false + azure-tenant-id: + required: false + azure-client-id: + required: false + azure-client-secret: + required: false + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + live: + runs-on: blacksmith-16vcpu-ubuntu-2204 + timeout-minutes: 30 + env: + MISE_ENV: test + steps: + # Fast pre-flight: fail in seconds if a secret was rotated or cleared, + # before the toolchain install and the compile. + - name: Require the secrets + env: + LIVE_KEY_VARIABLE: ${{ inputs.live-key-variable }} + REQUIRED_SECRETS: ${{ inputs.required-secrets }} + credentials_json: ${{ secrets.credentials-json }} + azure_tenant_id: ${{ secrets.azure-tenant-id }} + azure_client_id: ${{ secrets.azure-client-id }} + azure_client_secret: ${{ secrets.azure-client-secret }} + run: | + missing=0 + while read -r input secret; do + [ -n "$input" ] || continue + name="${input//-/_}" + if [ -z "${!name}" ]; then + echo "::error::$LIVE_KEY_VARIABLE is set but the secret $secret is empty" + missing=1 + fi + done <<< "$REQUIRED_SECRETS" + exit "$missing" + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + with: + version: 2026.4.0 + install: true + working_directory: . + cache: true + # Must name runs-on's image; see scripts/__tests__/workflow-mise-cache-image.test.mjs + cache_key_prefix: mise-v1-ubuntu-2204 + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + # Repository variables, not secrets. Each line must be NAME=value. + - name: Set the live key variables + env: + LIVE_ENV: ${{ inputs.live-env }} + run: | + while IFS= read -r line; do + [ -n "$line" ] || continue + if ! [[ "$line" =~ ^[A-Z][A-Z0-9_]*= ]]; then + echo "::error::live-env line is not NAME=value" + exit 1 + fi + echo "$line" >> "$GITHUB_ENV" + done <<< "$LIVE_ENV" + + - name: Write the credentials file + if: inputs.credentials-file-env != '' + env: + CREDENTIALS_JSON: ${{ secrets.credentials-json }} + CREDENTIALS_FILE_ENV: ${{ inputs.credentials-file-env }} + CRATE: ${{ inputs.crate }} + run: | + umask 077 + printf '%s' "$CREDENTIALS_JSON" > "$RUNNER_TEMP/$CRATE-live.json" + echo "$CREDENTIALS_FILE_ENV=$RUNNER_TEMP/$CRATE-live.json" >> "$GITHUB_ENV" + + - name: ${{ inputs.name }} + env: + AZURE_TENANT_ID: ${{ secrets.azure-tenant-id }} + AZURE_CLIENT_ID: ${{ secrets.azure-client-id }} + AZURE_CLIENT_SECRET: ${{ secrets.azure-client-secret }} + # The input is a constant in a caller in this repository. It is + # written into the command, not passed through the environment, so + # scripts/__tests__/crates-ci.test.mjs can see which task runs. + run: mise run test:live:${{ inputs.crate }} + + - name: Remove the credentials file + if: always() && inputs.credentials-file-env != '' + env: + CRATE: ${{ inputs.crate }} + run: rm -f "$RUNNER_TEMP/$CRATE-live.json" diff --git a/.github/workflows/integration-gcp-kms.yml b/.github/workflows/integration-gcp-kms.yml new file mode 100644 index 000000000..45d1aa5b1 --- /dev/null +++ b/.github/workflows/integration-gcp-kms.yml @@ -0,0 +1,76 @@ +name: Integration — stack-kms-gcp (Google Cloud KMS) + +# stack-kms-gcp against real Google Cloud KMS (tests/live.rs). Google +# publishes no Cloud KMS emulator, so unlike the LocalStack and Vault jobs in +# tests-crates.yml this needs a real crypto key and real credentials. Every +# other stack-kms-gcp 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_GCP_LIVE_KEY is set, so the workflow is inert until someone sets +# up the Google Cloud side. To turn it on: +# +# 1. Create a symmetric ENCRYPT_DECRYPT crypto key for this test only. +# 2. Create a service account with roles/cloudkms.cryptoKeyEncrypterDecrypter +# on that key and nothing else, and make a JSON key for it. +# 3. Set the repository variable STACK_KMS_GCP_LIVE_KEY to the crypto key +# name (projects/

/locations//keyRings//cryptoKeys/), and the +# repository secret STACK_KMS_GCP_SERVICE_ACCOUNT_KEY to the JSON key. +# +# 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-azure-key-vault.yml. This file keeps the triggers, the opt-in, +# the fork-PR condition and the names of the variable and the secret. +# +# 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-gcp/** + - packages/stack-kms-vendor/** + - packages/stack-kms/** + - Cargo.lock + - .github/workflows/integration-gcp-kms.yml + - .github/workflows/_test-kms-live.yml + pull_request: + # Repeated verbatim: GitHub Actions does not support YAML anchors/aliases. + paths: + - packages/stack-kms-gcp/** + - packages/stack-kms-vendor/** + - packages/stack-kms/** + - Cargo.lock + - .github/workflows/integration-gcp-kms.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-gcp against Cloud KMS + if: >- + vars.STACK_KMS_GCP_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-gcp + name: stack-kms-gcp against Cloud KMS + live-key-variable: STACK_KMS_GCP_LIVE_KEY + required-secrets: credentials-json STACK_KMS_GCP_SERVICE_ACCOUNT_KEY + live-env: STACK_KMS_GCP_LIVE_KEY=${{ vars.STACK_KMS_GCP_LIVE_KEY }} + # The test reads the service account key file this names. + credentials-file-env: GOOGLE_APPLICATION_CREDENTIALS + secrets: + credentials-json: ${{ secrets.STACK_KMS_GCP_SERVICE_ACCOUNT_KEY }} diff --git a/.github/workflows/tests-crates.yml b/.github/workflows/tests-crates.yml index cb5ad8ce5..d03c758bc 100644 --- a/.github/workflows/tests-crates.yml +++ b/.github/workflows/tests-crates.yml @@ -23,6 +23,7 @@ on: - packages/stack-kms-vendor/** - packages/stack-kms-aws/** - packages/stack-kms-vault/** + - packages/stack-kms-gcp/** - packages/stack-encrypt/** - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** @@ -50,6 +51,7 @@ on: - packages/stack-kms-vendor/** - packages/stack-kms-aws/** - packages/stack-kms-vault/** + - packages/stack-kms-gcp/** - packages/stack-encrypt/** - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** @@ -157,7 +159,7 @@ jobs: # with no `version`, or a crate that uses API its dependency's packaged # source does not have yet. stack-profile and stack-auth are in the list # because stack-kms and stack-encrypt depend on them: without them, cargo - # resolves the published versions, which lag the tree. stack-kms-vendor, stack-kms-aws and stack-kms-vault + # 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 # first version is published by hand); packaging them here keeps them # publishable meanwhile. No token. @@ -165,7 +167,7 @@ jobs: # version is on crates.io, cargo warns rather than fails on a dry run of # it. - name: Verify the stack-* crates package cleanly for crates.io - run: cargo publish --locked --dry-run --allow-dirty -p stack-profile -p stack-auth -p stack-kms -p stack-encrypt-derive -p stack-encrypt -p stack-kms-vendor -p stack-kms-aws -p stack-kms-vault + 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 # 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 3204e78bc..5da0fb766 100644 --- a/.github/workflows/udeps.yml +++ b/.github/workflows/udeps.yml @@ -17,6 +17,7 @@ on: - packages/stack-kms-vendor/** - packages/stack-kms-aws/** - packages/stack-kms-vault/** + - packages/stack-kms-gcp/** - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** @@ -39,6 +40,7 @@ on: - packages/stack-kms-vendor/** - packages/stack-kms-aws/** - packages/stack-kms-vault/** + - packages/stack-kms-gcp/** - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** diff --git a/AGENTS.md b/AGENTS.md index 66a92e0ef..4aac6867f 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`: The key vendor backends (ADR-0013 and ADR-0014 in `packages/stack-encrypt/docs/adr/`). `stack-kms-vendor` is the vendor seam (`KeyVendorBackend`), keyset records, the derived data key mode, the static key backend and the `VendorTransport` HTTP seam (feature `http`: the native reqwest transport). Its `test-support` feature holds the test code every vendor crate shares: the contract suite (`contract`), `FakeVendor`, and the recorded-fixture harness (`replay`: fixture types, `Replay`, `Recording`); a vendor crate's `tests/common/mod.rs` holds only its own emulator, keys and constants. `stack-kms-aws` is the AWS KMS backend: a hand-written JSON client with SigV4 signing in-crate and the AWS credential providers; its LocalStack test runs in `tests-crates.yml`'s `localstack` job. `stack-kms-vault` is the HashiCorp Vault Transit backend: a hand-written Transit client with token, AppRole and Kubernetes auth; its `vault server -dev` test runs in `tests-crates.yml`'s `vault` job. All three are root workspace members, publishable in the `stack-kms-vendor` version group, and staged in `release-plz.toml` (`release = false`) until their first version is published by hand. The `stack-kms-vendor` crate docs hold the capability matrix of every key backend (key lifetime, vendor calls, what the vendor sees and can refuse, credentials, rotation, known limits); change it with the code it describes. TypeScript (`@cipherstash/stack`) cannot use a key vendor backend yet. +- `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. - `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 nine stack-* +- **Three Cargo workspaces, not one.** The root workspace (the ten 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 eight crate `tasks.toml` files loses its last CI caller. + `mise.toml` or the nine 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 7c16eb6eb..b946f7986 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -645,6 +645,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -888,6 +894,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.5" @@ -933,6 +950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -944,7 +962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.1", "ctutils", "zeroize", @@ -1892,6 +1910,9 @@ name = "lazy_static" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -2214,6 +2235,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -2363,6 +2400,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2381,6 +2427,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2870,6 +2937,27 @@ dependencies = [ "serde", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust_decimal" version = "1.43.0" @@ -3265,6 +3353,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest 0.10.7", "rand_core 0.6.4", ] @@ -3334,6 +3423,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3541,6 +3646,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "stack-kms-gcp" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "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-vault" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9035ee64f..d06e36c6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "packages/stack-kms-vendor", "packages/stack-kms-aws", "packages/stack-kms-vault", + "packages/stack-kms-gcp", "packages/stack-encrypt", "packages/stack-encrypt-derive", "packages/stack-guest-abi", diff --git a/SECURITY.md b/SECURITY.md index 50b1ac515..9385834a0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,8 +38,8 @@ from the stack-* crates), released by `.github/workflows/release-golang.yml` as `languages/golang/vX.Y.Z` tags. All of these are in scope for security reports on the same terms as the npm packages above. -Three more Rust crates, **`stack-kms-vendor`**, **`stack-kms-aws`** and **`stack-kms-vault`** -(`packages/stack-kms-vendor`, `packages/stack-kms-aws`, `packages/stack-kms-vault`), are prepared for +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 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 0addab9a6..e2d110dde 100644 --- a/mise.toml +++ b/mise.toml @@ -13,6 +13,7 @@ includes = [ "packages/stack-kms-vendor/tasks.toml", "packages/stack-kms-aws/tasks.toml", "packages/stack-kms-vault/tasks.toml", + "packages/stack-kms-gcp/tasks.toml", "packages/stack-profile/tasks.toml", "packages/stack-encrypt/tasks.toml", "packages/stack-guest-abi/tasks.toml", @@ -133,6 +134,12 @@ check stack-kms-aws # The Vault Transit key vendor backend, default features on: its native # transport and Kubernetes auth are gated off on wasm32. check stack-kms-vault +# The Google Cloud KMS key vendor backend, default features on: its native +# transport, key files and metadata server are gated off on wasm32. Its RS256 +# signer (the `rsa` crate) is pure Rust. +check stack-kms-gcp +# Without `service-account`, the form a build with no RSA uses. +check stack-kms-gcp --no-default-features # 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-gcp/Cargo.toml b/packages/stack-kms-gcp/Cargo.toml new file mode 100644 index 000000000..2bb70724b --- /dev/null +++ b/packages/stack-kms-gcp/Cargo.toml @@ -0,0 +1,70 @@ +[package] +name = "stack-kms-gcp" +description = "The Google Cloud KMS key vendor backend for Stack Encrypt: a hand-written Cloud KMS client with service account and metadata server 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", "service-account"] +# `GcpKms::from_env`: the native transport (`HttpVendorTransport`) and the +# default credentials over it. Native only; on wasm32 it adds nothing. +http = ["stack-kms-vendor/http", "service-account"] +# `ServiceAccountKey` and `ServiceAccountLogin`: the RS256 JWT exchange. Off, +# the crate has no RSA code and no `rsa` dependency. The Go WASI guest turns +# it off: its host supplies the access token (ADR-0014), so the guest never +# signs with a service account key (RUSTSEC-2023-0071, see CIP-5311 in the +# decision log). +service-account = ["dep:rsa", "dep:rand_core"] + +[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"] } +# RS256 for the service account JWT. Pure Rust, so it builds for +# wasm32-wasip1 (ring and aws-lc-rs need a C toolchain there). See the +# CIP-5308 section of docs/plans/2026-11-05-key-vendor-backends-decisions.md +# for RUSTSEC-2023-0071. +rsa = { version = "0.9.10", default-features = false, features = ["std", "sha2", "pem", "u64_digit"], optional = true } +# The blinding RNG of the RSA signature (rsa 0.9 takes rand_core 0.6). +rand_core = { version = "0.6.4", features = ["getrandom"], optional = true } +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 } +aes-gcm = { version = "0.10.3", features = ["zeroize"] } +tokio = { workspace = true, features = ["rt", "macros", "time"] } +uuid = { workspace = true } +vitaminc = { workspace = true, features = ["protected", "random"] } + +# Builds `GcpKms::from_env`, which needs the native transport. +[[test]] +name = "live" +required-features = ["http"] + +[[test]] +name = "contract" +required-features = ["service-account"] + +[[test]] +name = "fixtures" +required-features = ["service-account"] + +[package.metadata.docs.rs] +all-features = true diff --git a/packages/stack-kms-gcp/LICENSE b/packages/stack-kms-gcp/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-kms-gcp/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-gcp/src/auth/metadata.rs b/packages/stack-kms-gcp/src/auth/metadata.rs new file mode 100644 index 000000000..e283c8309 --- /dev/null +++ b/packages/stack-kms-gcp/src/auth/metadata.rs @@ -0,0 +1,137 @@ +//! The metadata server's token for the attached service account. Native +//! only. + +use std::fmt; + +use stack_kms_vendor::{VendorRequest, VendorTransport}; + +use super::{read_token_response, AccessToken, AuthError, TokenProvider}; +use crate::config::EnvLookup; + +/// The host of the metadata server on Google Cloud. +pub const DEFAULT_METADATA_HOST: &str = "metadata.google.internal"; + +/// The path of the default service account's access token. +pub const METADATA_TOKEN_PATH: &str = "/computeMetadata/v1/instance/service-accounts/default/token"; + +/// The access token of the service account attached to the Compute Engine +/// instance, GKE workload (Workload Identity), Cloud Run service or Cloud +/// Functions function, from the metadata server. The request carries +/// `Metadata-Flavor: Google`. Native only. Wrap it in a +/// [`CachedToken`](super::CachedToken): the server caches the token too and +/// says how long it has left. +/// +/// The token has the scopes of the attached service account (on Compute +/// Engine, the instance's access scopes must include Cloud KMS or +/// `cloud-platform`). +pub struct MetadataServerLogin { + transport: T, + host: String, +} + +impl fmt::Debug for MetadataServerLogin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MetadataServerLogin") + .field("host", &self.host) + .finish_non_exhaustive() + } +} + +/// A host, with an optional port: `[A-Za-z0-9.-]` and at most one `:` +/// before digits. Nothing that could change the request path. +fn is_host(text: &str) -> bool { + let (host, port) = match text.split_once(':') { + Some((host, port)) => (host, Some(port)), + None => (text, None), + }; + !host.is_empty() + && host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-')) + && port.is_none_or(|port| { + !port.is_empty() && port.len() <= 5 && port.bytes().all(|b| b.is_ascii_digit()) + }) +} + +impl MetadataServerLogin { + /// Ask the metadata server at [`DEFAULT_METADATA_HOST`] through + /// `transport`. + pub fn new(transport: T) -> Self { + Self { + transport, + host: DEFAULT_METADATA_HOST.to_owned(), + } + } + + /// Ask the metadata server at `GCE_METADATA_HOST` (`host` or + /// `host:port`, the name the Google SDKs read) if it is set. + pub fn from_env_lookup(transport: T, env: &EnvLookup) -> Result { + let login = Self::new(transport); + match env("GCE_METADATA_HOST") { + Some(host) => login.with_host(host), + None => Ok(login), + } + } + + /// Ask the metadata server at `host` (`host` or `host:port`) instead. + pub fn with_host(mut self, host: impl Into) -> Result { + let host = host.into(); + if !is_host(&host) { + return Err(AuthError::access_denied(format!( + "{host:?} is not a metadata server host" + ))); + } + self.host = host; + Ok(self) + } + + /// The URL of the token. + pub fn url(&self) -> String { + format!("http://{}{METADATA_TOKEN_PATH}", self.host) + } + + /// The transport. + pub fn transport(&self) -> &T { + &self.transport + } +} + +impl TokenProvider for MetadataServerLogin { + async fn token(&self) -> Result { + let request = VendorRequest::new("GET", self.url()).header("metadata-flavor", "Google"); + let response = self.transport.send(request).await.map_err(|error| { + AuthError::unavailable(format!( + "the metadata server got no response (not on Google Cloud?): {error}" + )) + })?; + read_token_response("the metadata server", &response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hosts_are_checked() { + for good in [ + "metadata.google.internal", + "169.254.169.254", + "127.0.0.1:8080", + ] { + assert!(is_host(good), "{good}"); + } + for bad in [ + "", + "a/b", + "a:b", + "a:", + "a:123456", + "user@host", + "a b", + "a?b", + ] { + assert!(!is_host(bad), "{bad:?}"); + } + } +} diff --git a/packages/stack-kms-gcp/src/auth/mod.rs b/packages/stack-kms-gcp/src/auth/mod.rs new file mode 100644 index 000000000..8b71de162 --- /dev/null +++ b/packages/stack-kms-gcp/src/auth/mod.rs @@ -0,0 +1,328 @@ +//! OAuth 2.0 access tokens for Cloud KMS, and the credentials that get them. +//! +//! [`TokenProvider`] is the seam: the client asks it for an [`AccessToken`] +//! before each request. This crate provides: +//! +//! * [`AccessToken`] itself: a static token, for example from +//! `gcloud auth print-access-token`. Everywhere. It is used until Google +//! refuses it. +//! * [`ServiceAccountLogin`]: a service account key (the JSON file Google +//! issues, [`ServiceAccountKey`]) signs a JWT with RS256 and exchanges it +//! at the token endpoint (RFC 7523). Everywhere: the key can come from +//! bytes. Feature `service-account` (on by default). +//! * [`MetadataServerLogin`]: the token of the attached service account, +//! from the metadata server of Compute Engine, GKE, Cloud Run and Cloud +//! Functions. **Native only.** +//! * [`CachedToken`]: keeps the token a login returns and gets a new one +//! before it expires. +//! * [`DefaultCredentials`] (native, feature `service-account`): 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:** the `authorized_user` file that +//! `gcloud auth application-default login` writes, `external_account` +//! (workload identity federation) files, and service account +//! impersonation. Use a service account key, the metadata server, or a +//! static token. + +use std::future::Future; +#[cfg(any(feature = "service-account", not(target_arch = "wasm32")))] +use std::time::Duration; + +#[cfg(any(feature = "service-account", not(target_arch = "wasm32")))] +use serde::Deserialize; +#[cfg(any(feature = "service-account", not(target_arch = "wasm32")))] +use stack_kms::BackendErrorKind; +use stack_kms::MaybeSend; +#[cfg(any(feature = "service-account", not(target_arch = "wasm32")))] +use stack_kms_vendor::{classify_status, VendorResponse}; +use stack_kms_vendor::{CredentialError, CredentialKind, FetchCredential}; + +#[cfg(all(feature = "service-account", not(target_arch = "wasm32")))] +use crate::config::EnvLookup; + +#[cfg(not(target_arch = "wasm32"))] +mod metadata; +#[cfg(feature = "service-account")] +mod service_account; + +#[cfg(not(target_arch = "wasm32"))] +pub use metadata::{MetadataServerLogin, DEFAULT_METADATA_HOST, METADATA_TOKEN_PATH}; +#[cfg(feature = "service-account")] +pub use service_account::{ + ServiceAccountKey, ServiceAccountLogin, ASSERTION_LIFETIME, DEFAULT_TOKEN_URI, + JWT_BEARER_GRANT_TYPE, +}; + +/// The OAuth scope of Cloud KMS. +pub const CLOUD_KMS_SCOPE: &str = "https://www.googleapis.com/auth/cloudkms"; + +pub use stack_kms_vendor::{AccessToken, DEFAULT_REFRESH_BEFORE_EXPIRY}; + +/// The environment variable of a static access token. +pub const ACCESS_TOKEN_VARIABLE: &str = "GOOGLE_OAUTH_ACCESS_TOKEN"; + +/// The credential kind of this crate: an [`AccessToken`], one for the +/// client (Cloud KMS has one scope). It names [`AuthError`] and +/// [`CachedToken`]. +#[derive(Debug)] +pub enum GoogleCredentials {} + +impl CredentialKind for GoogleCredentials { + const LABEL: &'static str = "Google credentials"; + type Key = (); + type Credential = AccessToken; +} + +/// A token could not be got. The message starts with "Google +/// credentials". +pub type AuthError = CredentialError; + +/// Where a client gets its access token. The client asks before each +/// request, so a provider that logs in should cache ([`CachedToken`]). +/// +/// The returned future is bounded by [`MaybeSend`]: `Send` on native +/// targets, unbounded on wasm32. +pub trait TokenProvider: Send + Sync { + /// The current token. + fn token(&self) -> impl Future> + MaybeSend; + + /// Google refused the current token (HTTP 401). A caching provider + /// drops it, so that the next request gets a new one. The default does + /// nothing. + fn invalidate(&self) {} +} + +impl TokenProvider for AccessToken { + fn token(&self) -> impl Future> + MaybeSend { + std::future::ready(Ok(self.clone())) + } +} + +#[cfg(any(feature = "service-account", not(target_arch = "wasm32")))] +#[derive(Deserialize)] +struct TokenResponse { + access_token: Option, + #[serde(default)] + expires_in: u64, +} + +#[cfg(any(feature = "service-account", not(target_arch = "wasm32")))] +#[derive(Deserialize)] +struct TokenErrorBody { + error: Option, + error_description: Option, +} + +/// The access token of a token endpoint or metadata server response. +/// `source` names it in errors. +#[cfg(any(feature = "service-account", not(target_arch = "wasm32")))] +pub(crate) fn read_token_response( + source: &str, + response: &VendorResponse, +) -> Result { + if let Some(kind) = classify_status(response.status) { + // A refused grant is HTTP 400 (`invalid_grant`); a metadata server + // with no service account answers 404. Neither is a missing key or + // a damaged ciphertext. + let kind = match kind { + 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")))?, + ); + Ok(match parsed.expires_in { + 0 => token, + seconds => token.with_expires_in(Duration::from_secs(seconds)), + }) +} + +/// Keeps the token `inner` returns, and gets a new one when it is near its +/// expiry: [`DEFAULT_REFRESH_BEFORE_EXPIRY`] before, or a third of its life +/// before when it lives less than three times that. +/// +/// One fetch runs at a time: callers that arrive during a fetch wait for it +/// and share its result. If a fetch fails while the cached token has not +/// expired, the cached token is still returned. A token with no expiry is +/// kept until Google refuses it ([`TokenProvider::invalidate`]). +pub type CachedToken

= stack_kms_vendor::CachedToken; + +impl FetchCredential

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

{ + fn token(&self) -> impl Future> + MaybeSend { + self.get(&()) + } + + fn invalidate(&self) { + stack_kms_vendor::CachedToken::invalidate(self); + } +} + +#[cfg(all(feature = "service-account", 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), + /// The service account key file at `GOOGLE_APPLICATION_CREDENTIALS`. + ServiceAccount(Box>>), + /// The metadata server. + MetadataServer(CachedToken>), + } + + impl DefaultCredentials { + /// Pick credentials from the environment, in this order: + /// + /// 1. [`ACCESS_TOKEN_VARIABLE`] (`GOOGLE_OAUTH_ACCESS_TOKEN`): a + /// static access token. + /// 2. `GOOGLE_APPLICATION_CREDENTIALS`: the path of a service + /// account key file. A file of another type (an + /// `authorized_user` or `external_account` file) is refused. + /// 3. Otherwise the metadata server, at `GCE_METADATA_HOST` if set. + /// + /// 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)); + } + if let Some(path) = env("GOOGLE_APPLICATION_CREDENTIALS") { + let key = ServiceAccountKey::from_file(path)?; + return Ok(Self::ServiceAccount(Box::new(CachedToken::new( + ServiceAccountLogin::new(key, transport), + )))); + } + Ok(Self::MetadataServer(CachedToken::new( + MetadataServerLogin::from_env_lookup(transport, env)?, + ))) + } + } + + impl TokenProvider for DefaultCredentials { + async fn token(&self) -> Result { + match self { + Self::Token(token) => Ok(token.clone()), + Self::ServiceAccount(login) => login.token().await, + Self::MetadataServer(login) => login.token().await, + } + } + + fn invalidate(&self) { + match self { + Self::Token(_) => {} + Self::ServiceAccount(login) => login.invalidate(), + Self::MetadataServer(login) => login.invalidate(), + } + } + } +} + +#[cfg(all(feature = "service-account", not(target_arch = "wasm32")))] +pub use default::DefaultCredentials; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_responses_are_read_and_failures_classified() { + let ok = VendorResponse::new( + 200, + br#"{"access_token":"t","expires_in":3599,"token_type":"Bearer"}"#.to_vec(), + ); + let token = read_token_response("x", &ok).unwrap(); + assert_eq!(token.token(), "t"); + assert_eq!(token.expires_in(), Some(Duration::from_secs(3599))); + for (status, body, kind) in [ + ( + 400, + &br#"{"error":"invalid_grant","error_description":"Invalid JWT Signature."}"#[..], + BackendErrorKind::AccessDenied, + ), + (401, b"{}", BackendErrorKind::AccessDenied), + (404, b"not found", BackendErrorKind::AccessDenied), + (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())).unwrap_err(); + assert_eq!(error.kind(), kind, "{status}"); + } + let error = read_token_response( + "the token endpoint", + &VendorResponse::new( + 400, + br#"{"error":"invalid_grant","error_description":"Invalid JWT Signature."}"# + .to_vec(), + ), + ) + .unwrap_err(); + assert_eq!( + error.to_string(), + "Google credentials: the token endpoint failed with HTTP 400: \ + invalid_grant: Invalid JWT Signature." + ); + } +} diff --git a/packages/stack-kms-gcp/src/auth/service_account.rs b/packages/stack-kms-gcp/src/auth/service_account.rs new file mode 100644 index 000000000..b7cacb612 --- /dev/null +++ b/packages/stack-kms-gcp/src/auth/service_account.rs @@ -0,0 +1,279 @@ +//! Service account keys and the JWT bearer token exchange (RFC 7523). + +use std::fmt; +use std::time::Duration; + +use base64ct::{Base64UrlUnpadded, Encoding}; +use rsa::pkcs1v15::SigningKey; +use rsa::pkcs8::DecodePrivateKey; +use rsa::sha2::Sha256; +use rsa::signature::{RandomizedSigner, SignatureEncoding}; +use rsa::RsaPrivateKey; +use serde::{Deserialize, Serialize}; +use stack_kms_vendor::{VendorRequest, VendorTransport}; +use web_time::SystemTime; +use zeroize::Zeroizing; + +use super::{read_token_response, AccessToken, AuthError, TokenProvider, CLOUD_KMS_SCOPE}; +use crate::time::{system_clock, unix_seconds, Clock}; + +/// Google's OAuth 2.0 token endpoint, the `token_uri` of every service +/// account key Google issues. +pub const DEFAULT_TOKEN_URI: &str = "https://oauth2.googleapis.com/token"; + +/// The `grant_type` of the JWT bearer token exchange (RFC 7523). +pub const JWT_BEARER_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + +/// How long a signed assertion is valid: one hour, the most Google accepts. +pub const ASSERTION_LIFETIME: Duration = Duration::from_secs(3600); + +/// A service account key: the JSON file Google issues for a service +/// account (`"type": "service_account"`). +/// +/// The private key is wiped on drop and never printed. Only the fields the +/// token exchange needs are read: `client_email`, `private_key`, +/// `private_key_id` and `token_uri`. +pub struct ServiceAccountKey { + client_email: String, + private_key_id: Option, + token_uri: String, + signing_key: SigningKey, +} + +impl fmt::Debug for ServiceAccountKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ServiceAccountKey") + .field("client_email", &self.client_email) + .field("private_key_id", &self.private_key_id) + .field("token_uri", &self.token_uri) + .field("private_key", &"") + .finish() + } +} + +#[derive(Deserialize)] +struct KeyFile { + #[serde(rename = "type")] + kind: Option, + client_email: Option, + private_key: Option, + private_key_id: Option, + token_uri: Option, +} + +#[derive(Serialize)] +struct Header<'a> { + alg: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + kid: Option<&'a str>, + typ: &'static str, +} + +#[derive(Serialize)] +struct Claims<'a> { + iss: &'a str, + scope: &'a str, + aud: &'a str, + iat: u64, + exp: u64, +} + +impl ServiceAccountKey { + /// Read a service account key from the bytes of its JSON file. Refuses + /// another type of credentials file, a key that is not an RSA PKCS #8 + /// PEM key, and a `token_uri` that is not HTTPS. + pub fn from_json(json: &[u8]) -> Result { + let file: KeyFile = serde_json::from_slice(json) + .map_err(|_| AuthError::access_denied("the service account key is not JSON"))?; + match file.kind.as_deref() { + Some("service_account") => {} + Some(other) => { + return Err(AuthError::access_denied(format!( + "a credentials file of type {other:?} is not supported: \ + use a service account key (\"service_account\")" + ))) + } + None => { + return Err(AuthError::access_denied( + "the credentials file has no \"type\"", + )) + } + } + let client_email = file + .client_email + .filter(|email| !email.is_empty()) + .ok_or_else(|| { + AuthError::access_denied("the service account key has no client_email") + })?; + let pem = Zeroizing::new(file.private_key.ok_or_else(|| { + AuthError::access_denied("the service account key has no private_key") + })?); + let private_key = RsaPrivateKey::from_pkcs8_pem(&pem).map_err(|_| { + AuthError::access_denied("the private_key is not an RSA PKCS #8 PEM key") + })?; + let token_uri = file + .token_uri + .unwrap_or_else(|| DEFAULT_TOKEN_URI.to_owned()); + let parsed = url::Url::parse(&token_uri) + .map_err(|_| AuthError::access_denied("the token_uri is not a URL"))?; + if parsed.scheme() != "https" || parsed.host().is_none() { + return Err(AuthError::access_denied( + "the token_uri is not an HTTPS URL", + )); + } + Ok(Self { + client_email, + private_key_id: file.private_key_id.filter(|id| !id.is_empty()), + token_uri, + signing_key: SigningKey::::new(private_key), + }) + } + + /// Read a service account key file. Native only. + #[cfg(not(target_arch = "wasm32"))] + pub fn from_file(path: impl AsRef) -> Result { + let path = path.as_ref(); + let json = Zeroizing::new(std::fs::read(path).map_err(|error| { + AuthError::access_denied(format!( + "cannot read the service account key at {}: {}", + path.display(), + error.kind() + )) + })?); + Self::from_json(&json) + } + + /// The service account's email address. + pub fn client_email(&self) -> &str { + &self.client_email + } + + /// The id of the key, if the file names one. It is the `kid` of the + /// JWT header. + pub fn private_key_id(&self) -> Option<&str> { + self.private_key_id.as_deref() + } + + /// The token endpoint. + pub fn token_uri(&self) -> &str { + &self.token_uri + } + + /// The signed JWT assertion for `scope`, issued at `issued_at` and + /// valid for [`ASSERTION_LIFETIME`]: `{"alg":"RS256","kid":…,"typ":"JWT"}` + /// and `{"iss":…,"scope":…,"aud":,"iat":…,"exp":…}`, signed + /// with RSASSA-PKCS1-v1_5 and SHA-256 (RS256). The signature is + /// deterministic; the private key operation is blinded. + pub fn assertion( + &self, + scope: &str, + issued_at: SystemTime, + ) -> Result, AuthError> { + let iat = unix_seconds(issued_at); + let header = serde_json::to_vec(&Header { + alg: "RS256", + kid: self.private_key_id.as_deref(), + typ: "JWT", + }) + .map_err(|_| AuthError::access_denied("the JWT header"))?; + let claims = serde_json::to_vec(&Claims { + iss: &self.client_email, + scope, + aud: &self.token_uri, + iat, + exp: iat + ASSERTION_LIFETIME.as_secs(), + }) + .map_err(|_| AuthError::access_denied("the JWT claims"))?; + let mut jwt = Zeroizing::new(format!( + "{}.{}", + Base64UrlUnpadded::encode_string(&header), + Base64UrlUnpadded::encode_string(&claims) + )); + let signature = self + .signing_key + .try_sign_with_rng(&mut rand_core::OsRng, jwt.as_bytes()) + .map_err(|_| AuthError::unavailable("the JWT could not be signed"))?; + jwt.push('.'); + jwt.push_str(&Base64UrlUnpadded::encode_string(&signature.to_bytes())); + Ok(jwt) + } +} + +/// A service account key's token: a signed JWT assertion exchanged at the +/// key's `token_uri` for an access token (RFC 7523), with the Cloud KMS +/// scope. Google issues the token for one hour. Wrap it in a +/// [`CachedToken`](super::CachedToken). +pub struct ServiceAccountLogin { + key: ServiceAccountKey, + transport: T, + scope: String, + clock: Clock, +} + +impl fmt::Debug for ServiceAccountLogin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ServiceAccountLogin") + .field("key", &self.key) + .field("scope", &self.scope) + .finish_non_exhaustive() + } +} + +impl ServiceAccountLogin { + /// Exchange assertions signed by `key` through `transport`, for + /// [`CLOUD_KMS_SCOPE`]. + pub fn new(key: ServiceAccountKey, transport: T) -> Self { + Self { + key, + transport, + scope: CLOUD_KMS_SCOPE.to_owned(), + clock: system_clock(), + } + } + + /// Ask for `scope` instead (space-separated scopes), for example + /// `https://www.googleapis.com/auth/cloud-platform`. + pub fn with_scope(mut self, scope: impl Into) -> Self { + self.scope = scope.into(); + self + } + + /// Read the issue time of each assertion from `clock`. + pub fn with_clock(mut self, clock: Clock) -> Self { + self.clock = clock; + self + } + + /// The key. + pub fn key(&self) -> &ServiceAccountKey { + &self.key + } + + /// The transport. + pub fn transport(&self) -> &T { + &self.transport + } +} + +impl TokenProvider for ServiceAccountLogin { + async fn token(&self) -> Result { + let assertion = self.key.assertion(&self.scope, (self.clock)())?; + // The assertion is base64url and dots, so it needs no form escape. + let body = Zeroizing::new( + format!( + "grant_type={}&assertion={}", + url::form_urlencoded::byte_serialize(JWT_BEARER_GRANT_TYPE.as_bytes()) + .collect::(), + assertion.as_str() + ) + .into_bytes(), + ); + let mut request = VendorRequest::new("POST", self.key.token_uri()) + .header("content-type", "application/x-www-form-urlencoded"); + request.body = body; + let response = self.transport.send(request).await.map_err(|error| { + AuthError::unavailable(format!("the token endpoint got no response: {error}")) + })?; + read_token_response("the service account token exchange", &response) + } +} diff --git a/packages/stack-kms-gcp/src/client.rs b/packages/stack-kms-gcp/src/client.rs new file mode 100644 index 000000000..388e4ef83 --- /dev/null +++ b/packages/stack-kms-gcp/src/client.rs @@ -0,0 +1,606 @@ +//! The Cloud KMS client and its [`KeyVendorBackend`] implementation. + +use std::time::Duration; + +use base64ct::{Base64, Encoding}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use stack_kms::{BackendErrorKind, Error, KeyVendor}; +use stack_kms_vendor::{ + classify_status, KeyBytes, KeyVendorBackend, TransportError, VendorRequest, VendorResponse, + VendorTransport, +}; +use vitaminc::protected::{Controlled, Protected}; +use zeroize::Zeroizing; + +use crate::auth::{AuthError, TokenProvider}; +use crate::config::{parse_key_name, GcpKmsConfig}; +use crate::crc32c::crc32c; + +/// This client's vendor: what [`KeyVendorBackend::vendor`] answers and what its errors name. +const VENDOR: KeyVendor = KeyVendor::Gcp; + +/// The first byte of a wrapped key this client stores: the format of the +/// rest (`key version(4, BE) ‖ Cloud KMS ciphertext`). +pub const WRAPPED_KEY_FORMAT: u8 = 1; + +/// The text in an `INVALID_ARGUMENT` error that means "this ciphertext does +/// not open" ([`BackendErrorKind::InvalidCiphertext`]), lower case. Cloud +/// KMS says `Decryption failed: the ciphertext is invalid.` for damaged +/// bytes and other associated data, and `Decryption failed: verify that +/// 'name' refers to the correct CryptoKey.` for another key. +pub const INVALID_CIPHERTEXT_MARKERS: [&str; 2] = ["decryption failed", "ciphertext is invalid"]; + +/// The text in an `INVALID_ARGUMENT` error that means "a CRC32C did not +/// match the data" ([`BackendErrorKind::Unavailable`]), lower case: the +/// request was damaged on the way, so the same request can succeed later. +pub const CHECKSUM_MARKERS: [&str; 2] = ["checksum", "crc32c"]; + +/// A failure of a Cloud KMS 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 GcpKmsError { + /// Cloud KMS answered with an error. + #[error("Cloud KMS {operation} failed with HTTP {status}: {code}: {message}")] + Service { + /// The operation (`decrypt`). + operation: &'static str, + /// The HTTP status. + status: u16, + /// The `google.rpc.Code` name (`PERMISSION_DENIED`), or `unknown`. + code: String, + /// Google's error message. + message: String, + }, + /// The request got no response. + #[error("Cloud KMS {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), + /// Cloud KMS answered with a success that this client cannot read or + /// does not trust (a CRC32C that does not match). + #[error("Cloud KMS {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 Cloud KMS. + #[error("{0}")] + Refused(&'static str), +} + +fn backend(kind: BackendErrorKind, error: GcpKmsError) -> Error { + Error::Backend { + backend: VENDOR.into(), + kind, + source: Box::new(error), + } +} + +fn invalid(operation: &'static str, reason: &'static str) -> Error { + backend( + BackendErrorKind::Unavailable, + GcpKmsError::InvalidResponse { operation, reason }, + ) +} + +fn refused(kind: BackendErrorKind, reason: &'static str) -> Error { + backend(kind, GcpKmsError::Refused(reason)) +} + +/// The failure kind of a Cloud KMS error response: by the `google.rpc.Code` +/// name in `error.status` (`code`), then by the HTTP status. +/// +/// * `PERMISSION_DENIED`, `UNAUTHENTICATED`: `AccessDenied`. Note that Cloud +/// KMS answers `PERMISSION_DENIED`, not `NOT_FOUND`, for a key the caller +/// may not see. +/// * `NOT_FOUND`: `NotFound`. +/// * `RESOURCE_EXHAUSTED`: `Throttled` (a quota; the adapter backs off). +/// * `UNAVAILABLE`, `DEADLINE_EXCEEDED`, `INTERNAL`, `UNKNOWN`, `ABORTED`, +/// `CANCELLED`: `Unavailable`. `FAILED_PRECONDITION` (a disabled or +/// destroyed key version, a key with no primary version) is `Unavailable` +/// too, as AWS KMS's `DisabledException` is. +/// * `INVALID_ARGUMENT`: by the message, lower case. [`CHECKSUM_MARKERS`] +/// first (`Unavailable`), then [`INVALID_CIPHERTEXT_MARKERS`] +/// (`InvalidCiphertext`). Any other `INVALID_ARGUMENT` is a request Cloud +/// KMS refused (`AccessDenied`), as for AWS KMS and Vault. +/// * No or another code: the HTTP status (401 and 403 `AccessDenied`, 404 +/// `NotFound`, 429 `Throttled`, 5xx `Unavailable`); any other 4xx is +/// `AccessDenied`. +pub fn error_kind(status: u16, code: &str, message: &str) -> BackendErrorKind { + match code { + "PERMISSION_DENIED" | "UNAUTHENTICATED" => BackendErrorKind::AccessDenied, + "NOT_FOUND" => BackendErrorKind::NotFound, + "RESOURCE_EXHAUSTED" => BackendErrorKind::Throttled, + "UNAVAILABLE" + | "DEADLINE_EXCEEDED" + | "INTERNAL" + | "UNKNOWN" + | "ABORTED" + | "CANCELLED" + | "FAILED_PRECONDITION" => BackendErrorKind::Unavailable, + "INVALID_ARGUMENT" => { + let message = message.to_ascii_lowercase(); + if CHECKSUM_MARKERS.iter().any(|m| message.contains(m)) { + BackendErrorKind::Unavailable + } else if INVALID_CIPHERTEXT_MARKERS + .iter() + .any(|m| message.contains(m)) + { + BackendErrorKind::InvalidCiphertext + } else { + BackendErrorKind::AccessDenied + } + } + _ => match classify_status(status) { + // Another 4xx is a refused request, not a damaged ciphertext. + Some(BackendErrorKind::InvalidCiphertext) => BackendErrorKind::AccessDenied, + Some(kind) => kind, + None => BackendErrorKind::Unavailable, + }, + } +} + +#[derive(Deserialize)] +struct ErrorBody { + error: Option, +} + +#[derive(Deserialize)] +struct ErrorStatus { + message: Option, + status: Option, +} + +fn service_error(operation: &'static str, response: &VendorResponse) -> Error { + let status = serde_json::from_slice::(&response.body) + .ok() + .and_then(|body| body.error); + let (code, message) = match status { + Some(status) => ( + status.status.unwrap_or_else(|| "unknown".to_owned()), + status.message.unwrap_or_default(), + ), + None => ("unknown".to_owned(), String::new()), + }; + backend( + error_kind(response.status, &code, &message), + GcpKmsError::Service { + operation, + status: response.status, + code, + message, + }, + ) +} + +/// An `int64` of a JSON response: protobuf's JSON form is a string, but a +/// number is accepted too. +fn read_u64(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(text) => text.parse().ok(), + serde_json::Value::Number(number) => number.as_u64(), + _ => None, + } +} + +fn serialize_secret( + value: &Zeroizing, + serializer: S, +) -> Result { + serializer.serialize_str(value) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct EncryptRequest { + #[serde(serialize_with = "serialize_secret")] + plaintext: Zeroizing, + additional_authenticated_data: String, + plaintext_crc32c: String, + additional_authenticated_data_crc32c: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct EncryptResponse { + name: Option, + ciphertext: Option, + ciphertext_crc32c: Option, + #[serde(default)] + verified_plaintext_crc32c: bool, + #[serde(default)] + verified_additional_authenticated_data_crc32c: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct DecryptRequest { + ciphertext: String, + additional_authenticated_data: String, + ciphertext_crc32c: String, + additional_authenticated_data_crc32c: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DecryptResponse { + plaintext: Option, + plaintext_crc32c: Option, +} + +/// The stored form of a Cloud KMS ciphertext: [`WRAPPED_KEY_FORMAT`], the +/// number of the key version that encrypted it (4 bytes, big-endian), and +/// the ciphertext. +pub fn compact_ciphertext(version: u32, ciphertext: &[u8]) -> Vec { + let mut out = Vec::with_capacity(5 + ciphertext.len()); + out.push(WRAPPED_KEY_FORMAT); + out.extend_from_slice(&version.to_be_bytes()); + out.extend_from_slice(ciphertext); + out +} + +/// The key version and the Cloud KMS ciphertext of a stored wrapped key. +/// `None` if the bytes are not one. +pub fn split_wrapped_key(wrapped: &[u8]) -> Option<(u32, &[u8])> { + let (&format, rest) = wrapped.split_first()?; + if format != WRAPPED_KEY_FORMAT { + return None; + } + let (version, ciphertext) = rest.split_at_checked(4)?; + if ciphertext.is_empty() { + return None; + } + Some((u32::from_be_bytes(version.try_into().ok()?), ciphertext)) +} + +/// The number of the crypto 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 or destroy one. +pub fn wrapped_key_version(wrapped: &[u8]) -> Option { + split_wrapped_key(wrapped).map(|(version, _)| version) +} + +/// A Google Cloud KMS client, and the Google Cloud KMS key vendor backend +/// ([`KeyVendor::Gcp`]). +/// +/// It speaks the Cloud KMS REST API v1 (`cryptoKeys.encrypt` and +/// `cryptoKeys.decrypt`) with an OAuth 2.0 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 crypto key name, +/// `projects/{p}/locations/{l}/keyRings/{r}/cryptoKeys/{k}`, of a +/// symmetric `ENCRYPT_DECRYPT` key. Each `encrypt` uses the key's primary +/// version, and the client stores that version's number in the wrapped +/// key ([`wrapped_key_version`]); `decrypt` finds the version from the +/// ciphertext, so rotation needs no change to the record. A +/// `…/cryptoKeyVersions/{n}` name pins `encrypt` to that version. +/// * **Associated data.** The whole associated data (it holds the keyset +/// id, the vendor and the key mode) is sent as +/// `additionalAuthenticatedData`. Cloud KMS refuses a `decrypt` under +/// other associated data. +/// * **Integrity.** Every request carries the CRC32C of each field it +/// sends, and the client checks the CRC32C of each field Cloud KMS +/// returns, and that Cloud KMS verified the ones it was sent. +/// * **Calls.** The client wraps only a keyset's root key. One `encrypt` +/// creates a keyset record or rewraps it, and one `decrypt` 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 token ([`TokenProvider::invalidate`]). +pub struct GcpKms { + config: GcpKmsConfig, + transport: T, + auth: A, +} + +impl std::fmt::Debug for GcpKms { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GcpKms") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl GcpKms { + /// A client for `config`, sending through `transport`, with the access + /// tokens of `auth`. + pub fn new(config: GcpKmsConfig, transport: T, auth: A) -> Self { + Self { + config, + transport, + auth, + } + } + + /// The configuration. + pub fn config(&self) -> &GcpKmsConfig { + &self.config + } + + /// The transport. + pub fn transport(&self) -> &T { + &self.transport + } + + /// The token provider. + pub fn auth(&self) -> &A { + &self.auth + } + + /// Send one request to the API path `path` (after `/v1/`), with an + /// access token, and return the response of a success. + async fn request( + &self, + operation: &'static str, + method: &str, + path: &str, + body: Option>>, + ) -> Result { + let token = self.auth.token().await.map_err(|error| { + let kind = error.kind(); + backend(kind, GcpKmsError::Auth(error)) + })?; + let mut request = + VendorRequest::new(method, format!("{}/v1/{path}", self.config.endpoint())) + .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, + GcpKmsError::Transport { operation, source }, + ) + })?; + if classify_status(response.status).is_some() { + if response.status == 401 { + self.auth.invalidate(); + } + return Err(service_error(operation, &response)); + } + Ok(response) + } + + /// Send one request to the API path `path` (after `/v1/`, for example + /// `projects/p/locations/global/keyRings/r/cryptoKeys/k`) and read the + /// JSON of a success. `method` is the HTTP method. Any Cloud KMS method + /// can be called this way (for example `cryptoKeys.get` in a test); + /// [`KeyVendorBackend`] uses `encrypt` and `decrypt`. The body is wiped + /// after it is sent. + pub async fn send_json( + &self, + method: &str, + path: &str, + body: Option>>, + ) -> Result { + let response = self.request("request", method, path, body).await?; + serde_json::from_slice(&response.body).map_err(|_| invalid("request", "invalid JSON")) + } +} + +/// The crypto key name and the pinned version of `key_ref`. +fn key_name(key_ref: &str) -> Result<(&str, Option), Error> { + parse_key_name(key_ref).ok_or_else(|| { + refused( + BackendErrorKind::NotFound, + "the key reference is not a Cloud KMS crypto key name", + ) + }) +} + +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 KeyVendorBackend for GcpKms { + fn vendor(&self) -> KeyVendor { + VENDOR + } + + async fn wrap(&self, key_ref: &str, key: &KeyBytes, aad: &[u8]) -> Result, Error> { + let (crypto_key, _) = key_name(key_ref)?; + let body = to_json( + "encrypt", + &EncryptRequest { + plaintext: Zeroizing::new(Base64::encode_string(key.risky_ref())), + additional_authenticated_data: Base64::encode_string(aad), + plaintext_crc32c: crc32c(key.risky_ref()).to_string(), + additional_authenticated_data_crc32c: crc32c(aad).to_string(), + }, + )?; + let response = self + .request("encrypt", "POST", &format!("{key_ref}:encrypt"), Some(body)) + .await?; + let parsed: EncryptResponse = serde_json::from_slice(&response.body) + .map_err(|_| invalid("encrypt", "invalid JSON"))?; + if !parsed.verified_plaintext_crc32c + || !parsed.verified_additional_authenticated_data_crc32c + { + return Err(invalid( + "encrypt", + "Cloud KMS did not verify the CRC32C of the request", + )); + } + let ciphertext = Base64::decode_vec( + parsed + .ciphertext + .as_deref() + .ok_or_else(|| invalid("encrypt", "no ciphertext"))?, + ) + .map_err(|_| invalid("encrypt", "the ciphertext is not base64"))?; + if ciphertext.is_empty() { + return Err(invalid("encrypt", "an empty ciphertext")); + } + let expected = parsed + .ciphertext_crc32c + .as_ref() + .and_then(read_u64) + .ok_or_else(|| invalid("encrypt", "no ciphertext CRC32C"))?; + if u64::from(crc32c(&ciphertext)) != expected { + return Err(invalid("encrypt", "the ciphertext CRC32C does not match")); + } + let version = match parsed.name.as_deref().and_then(parse_key_name) { + Some((name, Some(version))) if name == crypto_key => version, + _ => { + return Err(invalid( + "encrypt", + "the response does not name a version of the key", + )) + } + }; + Ok(compact_ciphertext(version, &ciphertext)) + } + + async fn unwrap(&self, key_ref: &str, wrapped: &[u8], aad: &[u8]) -> Result { + let (crypto_key, _) = key_name(key_ref)?; + let (_, ciphertext) = split_wrapped_key(wrapped).ok_or_else(|| { + refused( + BackendErrorKind::InvalidCiphertext, + "the wrapped key is not a Cloud KMS wrapped key", + ) + })?; + let body = to_json( + "decrypt", + &DecryptRequest { + ciphertext: Base64::encode_string(ciphertext), + additional_authenticated_data: Base64::encode_string(aad), + ciphertext_crc32c: crc32c(ciphertext).to_string(), + additional_authenticated_data_crc32c: crc32c(aad).to_string(), + }, + )?; + let response = self + .request( + "decrypt", + "POST", + &format!("{crypto_key}:decrypt"), + Some(body), + ) + .await?; + let parsed: DecryptResponse = serde_json::from_slice(&response.body) + .map_err(|_| invalid("decrypt", "invalid JSON"))?; + let encoded = Zeroizing::new( + parsed + .plaintext + .ok_or_else(|| invalid("decrypt", "no plaintext"))?, + ); + let plaintext = Zeroizing::new( + Base64::decode_vec(&encoded) + .map_err(|_| invalid("decrypt", "the plaintext is not base64"))?, + ); + let expected = parsed + .plaintext_crc32c + .as_ref() + .and_then(read_u64) + .ok_or_else(|| invalid("decrypt", "no plaintext CRC32C"))?; + if u64::from(crc32c(&plaintext)) != expected { + return Err(invalid("decrypt", "the plaintext CRC32C does not match")); + } + let key: [u8; 32] = plaintext.as_slice().try_into().map_err(|_| { + refused( + BackendErrorKind::InvalidCiphertext, + "the wrapped key does not hold a 256-bit key", + ) + })?; + Ok(Protected::new(key)) + } + + async fn sleep(&self, duration: Duration) { + self.transport.sleep(duration).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn errors_map_by_code_then_status() { + use BackendErrorKind::*; + for (status, code, message, kind) in [ + (403, "PERMISSION_DENIED", "Permission denied", AccessDenied), + (401, "UNAUTHENTICATED", "", AccessDenied), + (404, "NOT_FOUND", "CryptoKey not found.", NotFound), + (429, "RESOURCE_EXHAUSTED", "Quota exceeded", Throttled), + (503, "UNAVAILABLE", "", Unavailable), + (504, "DEADLINE_EXCEEDED", "", Unavailable), + (500, "INTERNAL", "", Unavailable), + (500, "UNKNOWN", "", Unavailable), + (409, "ABORTED", "", Unavailable), + (499, "CANCELLED", "", Unavailable), + (400, "FAILED_PRECONDITION", "is not enabled", Unavailable), + ( + 400, + "INVALID_ARGUMENT", + "Decryption failed: the ciphertext is invalid.", + InvalidCiphertext, + ), + ( + 400, + "INVALID_ARGUMENT", + "Decryption failed: verify that 'name' refers to the correct CryptoKey.", + InvalidCiphertext, + ), + ( + 400, + "INVALID_ARGUMENT", + "The checksum in field ciphertext_crc32c did not match the data in field ciphertext.", + Unavailable, + ), + ( + 400, + "INVALID_ARGUMENT", + "Plaintext must be at most 65536 bytes.", + AccessDenied, + ), + (403, "unknown", "", AccessDenied), + (404, "unknown", "", NotFound), + (429, "unknown", "", Throttled), + (502, "unknown", "", Unavailable), + (412, "unknown", "", AccessDenied), + (302, "unknown", "", AccessDenied), + ] { + assert_eq!( + error_kind(status, code, message), + kind, + "{status} {code} {message}" + ); + } + } + + #[test] + fn wrapped_keys_split_losslessly() { + let wrapped = compact_ciphertext(7, b"ciphertext"); + assert_eq!(wrapped[0], WRAPPED_KEY_FORMAT); + assert_eq!(&wrapped[1..5], &7u32.to_be_bytes()); + assert_eq!(split_wrapped_key(&wrapped), Some((7, &b"ciphertext"[..]))); + assert_eq!(wrapped_key_version(&wrapped), Some(7)); + for bad in [&[][..], &[1, 0, 0, 0, 7], &[2, 0, 0, 0, 7, 1], &[1, 0, 0]] { + assert_eq!(split_wrapped_key(bad), None, "{bad:?}"); + } + } + + #[test] + fn integers_read_from_strings_and_numbers() { + assert_eq!( + read_u64(&serde_json::json!("4294967295")), + Some(4_294_967_295) + ); + assert_eq!(read_u64(&serde_json::json!(12)), Some(12)); + assert_eq!(read_u64(&serde_json::json!("x")), None); + assert_eq!(read_u64(&serde_json::json!(null)), None); + } +} diff --git a/packages/stack-kms-gcp/src/config.rs b/packages/stack-kms-gcp/src/config.rs new file mode 100644 index 000000000..c0aa8a188 --- /dev/null +++ b/packages/stack-kms-gcp/src/config.rs @@ -0,0 +1,184 @@ +//! The endpoint of a Cloud KMS client, and the crypto key names it accepts. + +/// 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 Cloud KMS API endpoint. +pub const DEFAULT_ENDPOINT: &str = "https://cloudkms.googleapis.com"; + +/// The configuration is not usable. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ConfigError { + /// The endpoint is not an absolute HTTP or HTTPS URL with no path, + /// query or credentials. + #[error("{0:?} is not an HTTP or HTTPS Cloud KMS endpoint")] + InvalidEndpoint(String), +} + +/// The endpoint of a [`GcpKms`](crate::GcpKms) client. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GcpKmsConfig { + endpoint: String, +} + +impl Default for GcpKmsConfig { + fn default() -> Self { + Self::new() + } +} + +impl GcpKmsConfig { + /// The public Cloud KMS endpoint, [`DEFAULT_ENDPOINT`]. + pub fn new() -> Self { + Self { + endpoint: DEFAULT_ENDPOINT.to_owned(), + } + } + + /// Send to `endpoint` instead: a regional or Private Service Connect + /// endpoint (`https://cloudkms.europe-west1.rep.googleapis.com`), or a + /// test server. A trailing `/` is removed. + pub fn with_endpoint(mut self, endpoint: impl Into) -> Result { + let endpoint = endpoint.into(); + let parsed = url::Url::parse(&endpoint) + .map_err(|_| ConfigError::InvalidEndpoint(endpoint.clone()))?; + if !matches!(parsed.scheme(), "http" | "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(ConfigError::InvalidEndpoint(endpoint)); + } + self.endpoint = parsed.as_str().trim_end_matches('/').to_owned(); + Ok(self) + } + + /// The endpoint, with no trailing `/`. + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + /// The URL of the method `method` (`encrypt`) on the resource `name`. + pub fn url(&self, name: &str, method: &str) -> String { + format!("{}/v1/{name}:{method}", self.endpoint) + } +} + +/// One resource id of a Cloud KMS name: `[A-Za-z0-9._-]`, not `.` or `..`. +/// Only a project id may hold a `:`, because it can be domain-scoped +/// (`example.com:project`); anywhere else a `:` would name a method. +fn is_id(text: &str, project: bool) -> bool { + !text.is_empty() + && text.len() <= 128 + && text != "." + && text != ".." + && text.bytes().all(|b| { + b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-') || (project && b == b':') + }) +} + +/// A crypto key name, `projects/{p}/locations/{l}/keyRings/{r}/cryptoKeys/{k}`, +/// optionally followed by `/cryptoKeyVersions/{n}`. +/// +/// Returns the crypto key name and the version, or `None` if `key_ref` is not +/// such a name. The client refuses anything else before any call, so a key +/// reference cannot change the request path. +pub fn parse_key_name(key_ref: &str) -> Option<(&str, Option)> { + let parts: Vec<&str> = key_ref.split('/').collect(); + let fixed = ["projects", "locations", "keyRings", "cryptoKeys"]; + if parts.len() != 8 && parts.len() != 10 { + return None; + } + for (index, collection) in fixed.iter().enumerate() { + if parts[2 * index] != *collection || !is_id(parts[2 * index + 1], index == 0) { + return None; + } + } + if parts.len() == 8 { + return Some((key_ref, None)); + } + let version = parts[9]; + if parts[8] != "cryptoKeyVersions" + || version.is_empty() + || version.len() > 10 + || !version.bytes().all(|b| b.is_ascii_digit()) + { + return None; + } + let version: u32 = version.parse().ok()?; + let key_len = key_ref.len() - "/cryptoKeyVersions/".len() - parts[9].len(); + Some((&key_ref[..key_len], Some(version))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &str = "projects/p-1/locations/global/keyRings/ring/cryptoKeys/key"; + + #[test] + fn the_endpoint_is_normalised_and_checked() { + let config = GcpKmsConfig::new(); + assert_eq!(config.endpoint(), DEFAULT_ENDPOINT); + assert_eq!( + config.url(KEY, "encrypt"), + format!("https://cloudkms.googleapis.com/v1/{KEY}:encrypt") + ); + let config = GcpKmsConfig::new() + .with_endpoint("http://127.0.0.1:9011/") + .unwrap(); + assert_eq!(config.endpoint(), "http://127.0.0.1:9011"); + for bad in [ + "cloudkms.googleapis.com", + "ftp://kms/", + "https://kms/v1", + "https://kms/?x=1", + "https://kms/#x", + "https://user:pass@kms/", + "not a url", + ] { + assert_eq!( + GcpKmsConfig::new().with_endpoint(bad), + Err(ConfigError::InvalidEndpoint(bad.to_owned())), + "{bad}" + ); + } + } + + #[test] + fn key_names_parse() { + assert_eq!(parse_key_name(KEY), Some((KEY, None))); + let version = format!("{KEY}/cryptoKeyVersions/12"); + assert_eq!(parse_key_name(&version), Some((KEY, Some(12)))); + let scoped = "projects/example.com:p/locations/us-east1/keyRings/r_1/cryptoKeys/k-2"; + assert_eq!(parse_key_name(scoped), Some((scoped, None))); + for bad in [ + "", + "key", + "projects/p/locations/l/keyRings/r", + "projects/p/locations/l/keyRings/r/cryptoKeys/", + "projects/p/locations/l/keyRings/r/cryptoKeys/k/", + "projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions", + "projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/x", + "projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/99999999999", + "projects/p/locations/l/keyRings/r/cryptoKeys/k/versions/1", + "projects/p/locations/l/keyRings/../cryptoKeys/k", + "projects/p/locations/l/keyRings/r/cryptoKeys/k?x", + "projects/p/locations/l/keyRings/r/cryptoKeys/k:decrypt", + "projects/p/locations/l/keyRings/r/cryptoKeys/a b", + "folders/p/locations/l/keyRings/r/cryptoKeys/k", + "/projects/p/locations/l/keyRings/r/cryptoKeys/k", + ] { + assert_eq!(parse_key_name(bad), None, "{bad:?}"); + } + } +} diff --git a/packages/stack-kms-gcp/src/crc32c.rs b/packages/stack-kms-gcp/src/crc32c.rs new file mode 100644 index 000000000..ff25e9244 --- /dev/null +++ b/packages/stack-kms-gcp/src/crc32c.rs @@ -0,0 +1,58 @@ +//! CRC32C (Castagnoli), for the Cloud KMS integrity fields. +//! +//! Cloud KMS checks a CRC32C of each request field it is given, and returns +//! one of each response field. This is the table-driven, reflected CRC with +//! polynomial `0x82F63B78`, initial value and final XOR `0xFFFFFFFF` +//! (RFC 3720, appendix B.4). + +/// The reflected Castagnoli polynomial. +const POLYNOMIAL: u32 = 0x82F6_3B78; + +const fn table() -> [u32; 256] { + let mut table = [0u32; 256]; + let mut n = 0; + while n < 256 { + let mut crc = n as u32; + let mut bit = 0; + while bit < 8 { + crc = if crc & 1 == 1 { + (crc >> 1) ^ POLYNOMIAL + } else { + crc >> 1 + }; + bit += 1; + } + table[n] = crc; + n += 1; + } + table +} + +const TABLE: [u32; 256] = table(); + +/// The CRC32C of `bytes`. +pub fn crc32c(bytes: &[u8]) -> u32 { + let mut crc = u32::MAX; + for &byte in bytes { + crc = TABLE[((crc ^ u32::from(byte)) & 0xFF) as usize] ^ (crc >> 8); + } + !crc +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_answers() { + // The check value of CRC-32C, and RFC 3720 B.4's test vectors. + assert_eq!(crc32c(b"123456789"), 0xE306_9283); + assert_eq!(crc32c(b""), 0); + assert_eq!(crc32c(&[0u8; 32]), 0x8A91_36AA); + assert_eq!(crc32c(&[0xFFu8; 32]), 0x62A8_AB43); + let ascending: Vec = (0u8..32).collect(); + assert_eq!(crc32c(&ascending), 0x46DD_794E); + let descending: Vec = (0u8..32).rev().collect(); + assert_eq!(crc32c(&descending), 0x113F_DB5C); + } +} diff --git a/packages/stack-kms-gcp/src/lib.rs b/packages/stack-kms-gcp/src/lib.rs new file mode 100644 index 000000000..7d38bbb44 --- /dev/null +++ b/packages/stack-kms-gcp/src/lib.rs @@ -0,0 +1,158 @@ +#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +//! The Google Cloud KMS key vendor backend for Stack Encrypt (ADR-0013, +//! ADR-0014). +//! +//! [`GcpKms`] is a small, hand-written client for the Cloud KMS REST API v1: +//! `cryptoKeys.encrypt` and `cryptoKeys.decrypt`, with the associated data +//! as `additionalAuthenticatedData` and CRC32C integrity checks on every +//! field. 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 Google SDK crate is used, so this crate also +//! builds for wasm32-wasip1, the Go module's WASI guest. +//! +//! Cloud KMS wraps only the root key of a keyset. One `encrypt` call +//! creates a keyset record or rewraps it. One `decrypt` 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 Cloud KMS call. +//! +//! # Native Rust +//! +//! ```no_run +//! # async fn example() -> Result<(), Box> { +//! use stack_kms_gcp::GcpKms; +//! use stack_kms_vendor::{KeysetRecord, VendorKeySource}; +//! use stack_kms::KeyMode; +//! +//! // GOOGLE_APPLICATION_CREDENTIALS (a service account key file), or the +//! // metadata server on Google Cloud. +//! let kms = GcpKms::from_env()?; +//! let record = KeysetRecord::create( +//! &kms, +//! "projects/my-project/locations/global/keyRings/cipherstash/cryptoKeys/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. +//! +//! # IAM +//! +//! The service account needs `roles/cloudkms.cryptoKeyEncrypterDecrypter` on +//! the crypto key (the permissions `cloudkms.cryptoKeyVersions.useToEncrypt` +//! and `useToDecrypt`). The key must be a symmetric `ENCRYPT_DECRYPT` key. +//! Rotate it in Cloud KMS: a keyset record names the version that wrapped +//! its root key ([`wrapped_key_version`]), and decrypt still works under an +//! old version until you disable or destroy it. +//! +//! # The WASI guest +//! +//! The guest builds [`GcpKms::new`] with its host transport and a +//! [`TokenProvider`] over a host import. The metadata server and key files +//! 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; +pub mod crc32c; +mod time; + +#[cfg(all(feature = "service-account", not(target_arch = "wasm32")))] +pub use auth::DefaultCredentials; +#[cfg(not(target_arch = "wasm32"))] +pub use auth::MetadataServerLogin; +pub use auth::{ + AccessToken, AuthError, CachedToken, GoogleCredentials, TokenProvider, ACCESS_TOKEN_VARIABLE, + CLOUD_KMS_SCOPE, DEFAULT_REFRESH_BEFORE_EXPIRY, +}; +#[cfg(feature = "service-account")] +pub use auth::{ServiceAccountKey, ServiceAccountLogin, DEFAULT_TOKEN_URI}; +pub use client::{ + compact_ciphertext, error_kind, split_wrapped_key, wrapped_key_version, GcpKms, GcpKmsError, + CHECKSUM_MARKERS, INVALID_CIPHERTEXT_MARKERS, WRAPPED_KEY_FORMAT, +}; +pub use config::{ + parse_key_name, process_env, ConfigError, EnvLookup, GcpKmsConfig, DEFAULT_ENDPOINT, +}; +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::{GcpKms, GcpKmsConfig}; + + /// [`GcpKms::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 [`GcpKms::from_env`] builds. + pub type DefaultGcpKms = GcpKms>; + + impl DefaultGcpKms { + /// A client of the public Cloud KMS endpoint, with the credentials + /// of [`DefaultCredentials::from_env`]. Native only. + /// + /// The credentials transport opens connections with a 1 second + /// limit, so that off Google Cloud the metadata server probe fails + /// fast. + pub fn from_env() -> Result { + Self::from_env_with(GcpKmsConfig::new()) + } + + /// As [`from_env`](Self::from_env), with `config` (for example + /// another endpoint). + pub fn from_env_with(config: GcpKmsConfig) -> 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(GcpKms::new(config, HttpVendorTransport::new()?, auth)) + } + } +} + +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +pub use native::{DefaultGcpKms, FromEnvError}; diff --git a/packages/stack-kms-gcp/src/time.rs b/packages/stack-kms-gcp/src/time.rs new file mode 100644 index 000000000..4134c614c --- /dev/null +++ b/packages/stack-kms-gcp/src/time.rs @@ -0,0 +1,16 @@ +//! The time source of a client. + +#[cfg(feature = "service-account")] +use web_time::SystemTime; + +/// The time source of a token cache and of the service account JWT: +/// `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. +#[cfg(feature = "service-account")] +pub(crate) fn unix_seconds(time: SystemTime) -> u64 { + time.duration_since(web_time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or(0) +} diff --git a/packages/stack-kms-gcp/tasks.toml b/packages/stack-kms-gcp/tasks.toml new file mode 100644 index 000000000..ec1fa21dd --- /dev/null +++ b/packages/stack-kms-gcp/tasks.toml @@ -0,0 +1,31 @@ +# 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-gcp"] +description = "Build docs for stack-kms-gcp with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-kms-gcp --no-deps --all-features" + +# The Cloud KMS backend against real Google Cloud KMS (tests/live.rs). Google +# publishes no Cloud KMS emulator, so this needs a real key and credentials. +# The test skips itself when STACK_KMS_GCP_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-gcp-kms.yml (opt-in, credentialed) calls this task. +# +# STACK_KMS_GCP_LIVE_KEY=projects/

/locations//keyRings//cryptoKeys/ \ +# GOOGLE_APPLICATION_CREDENTIALS= \ +# mise run test:live:stack-kms-gcp +["test:live:stack-kms-gcp"] +description = "Run the stack-kms-gcp tests against real Cloud KMS (needs STACK_KMS_GCP_LIVE_KEY and credentials)" +run = """ +if [ -z "${STACK_KMS_GCP_LIVE_KEY:-}" ]; then + echo "error: set STACK_KMS_GCP_LIVE_KEY to a crypto key name (projects/.../cryptoKeys/...)" >&2 + exit 1 +fi +if [ -z "${GOOGLE_APPLICATION_CREDENTIALS:-}" ] && [ -z "${GOOGLE_OAUTH_ACCESS_TOKEN:-}" ]; then + echo "error: set GOOGLE_APPLICATION_CREDENTIALS (a service account key file) or GOOGLE_OAUTH_ACCESS_TOKEN" >&2 + exit 1 +fi +mise x --env test -- cargo nextest run --locked -p stack-kms-gcp --all-features --test live --no-tests=fail +""" diff --git a/packages/stack-kms-gcp/tests/common/mod.rs b/packages/stack-kms-gcp/tests/common/mod.rs new file mode 100644 index 000000000..45afc57f0 --- /dev/null +++ b/packages/stack-kms-gcp/tests/common/mod.rs @@ -0,0 +1,441 @@ +//! Shared test support: the test-only service account, and an in-process +//! emulation of the Cloud KMS API, the token endpoint and the metadata +//! server. 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_gcm::aead::{Aead, KeyInit, Payload}; +use aes_gcm::{Aes256Gcm, Nonce}; +use base64ct::{Base64, Base64UrlUnpadded, Encoding}; +use rsa::pkcs1v15::{Signature, VerifyingKey}; +use rsa::pkcs8::DecodePrivateKey; +use rsa::sha2::Sha256; +use rsa::signature::Verifier; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; +use stack_kms_gcp::crc32c::crc32c; +use stack_kms_gcp::{parse_key_name, AccessToken, GcpKms, GcpKmsConfig, ServiceAccountKey}; +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 crypto key of every fixture. +pub const KEY: &str = + "projects/test-only-project/locations/global/keyRings/cipherstash-fixture/cryptoKeys/fixture-key"; +/// The static access token of the fixtures. +pub const TOKEN: &str = "fixture-access-token"; +/// The email of the test-only service account. +pub const EMAIL: &str = "stack-kms-gcp-fixture@test-only-project.iam.gserviceaccount.com"; +/// The key id of the test-only service account key. +pub const KEY_ID: &str = "test-only-fixture-key-id"; +/// The time every fixture JWT is issued at: 2026-11-05T12:00:00Z. +pub const ISSUED_AT: u64 = 1_793_880_000; +/// The Cloud KMS endpoint. +pub const ENDPOINT: &str = "https://cloudkms.googleapis.com"; +/// The token endpoint. +pub const TOKEN_URI: &str = "https://oauth2.googleapis.com/token"; +/// The metadata server's token URL. +pub const METADATA_URL: &str = + "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"; + +/// The time of the fixture JWT. +pub fn issued_at() -> SystemTime { + UNIX_EPOCH + Duration::from_secs(ISSUED_AT) +} + +/// The PEM of the TEST-ONLY RSA key, generated for these tests with +/// `openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048`. It is no +/// one's service account 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_key_pem() -> String { + 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(); + let lines: Vec = body + .as_bytes() + .chunks(64) + .map(|chunk| String::from_utf8(chunk.to_vec()).unwrap()) + .collect(); + // The PEM label is built in pieces so that no file in the tree holds + // a PEM private key block. + let label = ["PRIVATE", "KEY"].join(" "); + format!( + "-----BEGIN {label}-----\n{}\n-----END {label}-----\n", + lines.join("\n") + ) +} + +/// A service account key file for the test-only key, built at run time. +pub fn test_only_service_account_json() -> Vec { + serde_json::to_vec(&json!({ + "type": "service_account", + "project_id": "test-only-project", + "private_key_id": KEY_ID, + "private_key": test_only_key_pem(), + "client_email": EMAIL, + "client_id": "000000000000000000000", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": TOKEN_URI, + })) + .unwrap() +} + +pub fn test_only_service_account() -> ServiceAccountKey { + ServiceAccountKey::from_json(&test_only_service_account_json()).unwrap() +} + +/// Verify a JWT's RS256 signature with the test-only key, and return its +/// header and claims. +pub fn verify_jwt(jwt: &str) -> Option<(Value, Value)> { + let (signing_input, signature) = jwt.rsplit_once('.')?; + let (header, claims) = signing_input.split_once('.')?; + let private = rsa::RsaPrivateKey::from_pkcs8_pem(&test_only_key_pem()).unwrap(); + let verifying = VerifyingKey::::new(private.to_public_key()); + let signature = + Signature::try_from(Base64UrlUnpadded::decode_vec(signature).ok()?.as_slice()).ok()?; + verifying + .verify(signing_input.as_bytes(), &signature) + .ok()?; + let header = serde_json::from_slice(&Base64UrlUnpadded::decode_vec(header).ok()?).ok()?; + let claims = serde_json::from_slice(&Base64UrlUnpadded::decode_vec(claims).ok()?).ok()?; + Some((header, claims)) +} + +/// 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 of the public endpoint over `transport`, with the fixture +/// token. +pub fn fixture_client(transport: T) -> GcpKms { + GcpKms::new(GcpKmsConfig::new(), transport, AccessToken::new(TOKEN)) +} + +#[derive(Debug, Default)] +struct EmulatorState { + /// Each crypto key's versions; the last is the primary. + keys: HashMap>, + tokens: Vec, + exchanges: usize, + requests: Vec<(String, String, Value)>, + sleeps: Vec, + nonce: u32, +} + +/// An in-process emulation of the Cloud KMS REST API (`encrypt` and +/// `decrypt` of symmetric keys with versions, AES-256-GCM with the +/// associated data bound, the CRC32C checks, Google's error shapes), the +/// OAuth token endpoint (which verifies the JWT's RS256 signature with the +/// test-only key) and the metadata server. It checks the bearer token on +/// every Cloud KMS request. +/// +/// The contract suite uses random keys and ids, so fixed bytes cannot serve +/// it: this answers it instead. Clones share one state. +#[derive(Clone, Debug, Default)] +pub struct KmsEmulator { + state: Arc>, +} + +impl KmsEmulator { + /// An emulator holding [`KEY`] (one version), that accepts [`TOKEN`]. + pub fn new() -> Self { + let emulator = Self::default(); + emulator.add_key(KEY); + emulator.state.lock().unwrap().tokens.push(TOKEN.to_owned()); + emulator + } + + pub fn add_key(&self, name: &str) { + let mut state = self.state.lock().unwrap(); + let seed = state.keys.len() as u8; + state.keys.insert(name.to_owned(), vec![[seed ^ 0x5A; 32]]); + } + + /// Add a version to `name` and make it the primary. + pub fn rotate(&self, name: &str) { + let mut state = self.state.lock().unwrap(); + let versions = state.keys.get_mut(name).unwrap(); + let next = [versions.len() as u8 ^ 0xA5; 32]; + versions.push(next); + } + + /// Revoke every access token. + pub fn revoke_tokens(&self) { + self.state.lock().unwrap().tokens.clear(); + } + + /// Token endpoint and metadata server exchanges so far. + pub fn exchanges(&self) -> usize { + self.state.lock().unwrap().exchanges + } + + /// Every Cloud KMS request: method, path after `/v1/`, 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, message: &str) -> VendorResponse { + Self::json( + status, + json!({ "error": { "code": status, "message": message, "status": code } }), + ) + } + + fn issue(&self, prefix: &str) -> VendorResponse { + let mut state = self.state.lock().unwrap(); + state.exchanges += 1; + let token = format!("{prefix}-{}", state.exchanges); + state.tokens.push(token.clone()); + Self::json( + 200, + json!({ "access_token": token, "expires_in": 3599, "token_type": "Bearer" }), + ) + } + + fn token_exchange(&self, body: &[u8]) -> VendorResponse { + let form: HashMap = + url::form_urlencoded::parse(body).into_owned().collect(); + if form.get("grant_type").map(String::as_str) + != Some("urn:ietf:params:oauth:grant-type:jwt-bearer") + { + return Self::json(400, json!({ "error": "unsupported_grant_type" })); + } + let Some((header, claims)) = form.get("assertion").and_then(|jwt| verify_jwt(jwt)) else { + return Self::json( + 400, + json!({ "error": "invalid_grant", "error_description": "Invalid JWT Signature." }), + ); + }; + let valid = header["alg"] == "RS256" + && header["kid"] == KEY_ID + && claims["iss"] == EMAIL + && claims["aud"] == TOKEN_URI + && claims["scope"] == "https://www.googleapis.com/auth/cloudkms" + && claims["exp"].as_u64() == claims["iat"].as_u64().map(|iat| iat + 3600); + if !valid { + return Self::json( + 400, + json!({ "error": "invalid_grant", "error_description": "Invalid JWT claims." }), + ); + } + self.issue("fixture-exchanged-token") + } + + fn check_crc(body: &Value, field: &str, crc_field: &str) -> Result, VendorResponse> { + let bytes = Base64::decode_vec(body[field].as_str().unwrap_or("")).map_err(|_| { + Self::error( + 400, + "INVALID_ARGUMENT", + &format!("Invalid value at '{field}'"), + ) + })?; + let sent = body[crc_field] + .as_str() + .and_then(|crc| crc.parse::().ok()); + if sent != Some(u64::from(crc32c(&bytes))) { + return Err(Self::error( + 400, + "INVALID_ARGUMENT", + &format!( + "The checksum in field {crc_field} did not match the data in field {field}." + ), + )); + } + Ok(bytes) + } + + fn encrypt(&self, name: &str, body: &Value) -> VendorResponse { + let Some((key_name, pinned)) = parse_key_name(name) else { + return Self::error(400, "INVALID_ARGUMENT", "Invalid resource name"); + }; + let mut state = self.state.lock().unwrap(); + let Some(versions) = state.keys.get(key_name).cloned() else { + return Self::error(404, "NOT_FOUND", &format!("{key_name} not found.")); + }; + let plaintext = match Self::check_crc(body, "plaintext", "plaintextCrc32c") { + Ok(bytes) => bytes, + Err(response) => return response, + }; + let aad = match Self::check_crc( + body, + "additionalAuthenticatedData", + "additionalAuthenticatedDataCrc32c", + ) { + Ok(bytes) => bytes, + Err(response) => return response, + }; + let version = pinned.unwrap_or(versions.len() as u32); + let Some(key) = versions.get(version as usize - 1) else { + return Self::error(404, "NOT_FOUND", "CryptoKeyVersion not found."); + }; + state.nonce += 1; + let mut nonce = [0u8; 12]; + nonce[..4].copy_from_slice(&state.nonce.to_be_bytes()); + let mut ciphertext = version.to_be_bytes().to_vec(); + ciphertext.extend_from_slice(&nonce); + ciphertext.extend( + Aes256Gcm::new_from_slice(key) + .unwrap() + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &plaintext, + aad: &aad, + }, + ) + .unwrap(), + ); + Self::json( + 200, + json!({ + "name": format!("{key_name}/cryptoKeyVersions/{version}"), + "ciphertext": Base64::encode_string(&ciphertext), + "ciphertextCrc32c": crc32c(&ciphertext).to_string(), + "verifiedPlaintextCrc32c": true, + "verifiedAdditionalAuthenticatedDataCrc32c": true, + "protectionLevel": "SOFTWARE", + }), + ) + } + + fn decrypt(&self, name: &str, body: &Value) -> VendorResponse { + match parse_key_name(name) { + Some((_, None)) => {} + _ => return Self::error(400, "INVALID_ARGUMENT", "Invalid resource name"), + } + let Some(versions) = self.state.lock().unwrap().keys.get(name).cloned() else { + return Self::error(404, "NOT_FOUND", &format!("{name} not found.")); + }; + let ciphertext = match Self::check_crc(body, "ciphertext", "ciphertextCrc32c") { + Ok(bytes) => bytes, + Err(response) => return response, + }; + let aad = match Self::check_crc( + body, + "additionalAuthenticatedData", + "additionalAuthenticatedDataCrc32c", + ) { + Ok(bytes) => bytes, + Err(response) => return response, + }; + let invalid = || { + Self::error( + 400, + "INVALID_ARGUMENT", + "Decryption failed: the ciphertext is invalid.", + ) + }; + if ciphertext.len() < 16 { + return invalid(); + } + let version = u32::from_be_bytes(ciphertext[..4].try_into().unwrap()); + let Some(key) = versions.get((version as usize).wrapping_sub(1)) else { + return invalid(); + }; + let Ok(plaintext) = Aes256Gcm::new_from_slice(key).unwrap().decrypt( + Nonce::from_slice(&ciphertext[4..16]), + Payload { + msg: &ciphertext[16..], + aad: &aad, + }, + ) else { + return invalid(); + }; + Self::json( + 200, + json!({ + "plaintext": Base64::encode_string(&plaintext), + "plaintextCrc32c": crc32c(&plaintext).to_string(), + "usedPrimary": version as usize == versions.len(), + "protectionLevel": "SOFTWARE", + }), + ) + } +} + +impl VendorTransport for KmsEmulator { + async fn send(&self, request: VendorRequest) -> Result { + if request.url == TOKEN_URI { + assert_eq!(request.method, "POST"); + assert_eq!( + request.header_value("content-type"), + Some("application/x-www-form-urlencoded") + ); + return Ok(self.token_exchange(&request.body)); + } + if request.url == METADATA_URL { + if request.header_value("metadata-flavor") != Some("Google") { + return Ok(VendorResponse::new( + 403, + b"Missing Metadata-Flavor".to_vec(), + )); + } + return Ok(self.issue("fixture-metadata-token")); + } + let path = request + .url + .strip_prefix(&format!("{ENDPOINT}/v1/")) + .expect("a Cloud KMS URL") + .to_owned(); + let body = body_json(&request.body); + { + let mut state = self.state.lock().unwrap(); + state + .requests + .push((request.method.clone(), path.clone(), body.clone())); + let token = request + .header_value("authorization") + .and_then(|value| value.strip_prefix("Bearer ")) + .unwrap_or(""); + if !state.tokens.iter().any(|t| t == token) { + return Ok(Self::error( + 401, + "UNAUTHENTICATED", + "Request had invalid authentication credentials.", + )); + } + } + assert_eq!(request.method, "POST"); + assert_eq!( + request.header_value("content-type"), + Some("application/json") + ); + Ok(match path.rsplit_once(':') { + Some((name, "encrypt")) => self.encrypt(name, &body), + Some((name, "decrypt")) => self.decrypt(name, &body), + _ => Self::error(404, "NOT_FOUND", "Method not found."), + }) + } + + async fn sleep(&self, duration: Duration) { + self.state.lock().unwrap().sleeps.push(duration); + } +} diff --git a/packages/stack-kms-gcp/tests/contract.rs b/packages/stack-kms-gcp/tests/contract.rs new file mode 100644 index 000000000..bcec47b18 --- /dev/null +++ b/packages/stack-kms-gcp/tests/contract.rs @@ -0,0 +1,176 @@ +//! The shared contract suite, run against the Cloud KMS backend over an +//! in-process emulation of the Cloud KMS API. +//! +//! The suite generates random keys and keyset ids, so fixed exchanges +//! cannot answer it; the emulator does (see `common::KmsEmulator`). The +//! request shapes are pinned by `tests/fixtures.rs`, and real Cloud KMS is +//! exercised by `tests/live.rs`. + +mod common; + +use common::{fixture_client, test_only_service_account, KmsEmulator, KEY}; +use stack_encrypt::StackCipher; +use stack_kms::{BackendErrorKind, Error, KeyMode, KeyVendor}; +use stack_kms_gcp::{wrapped_key_version, CachedToken, GcpKms, GcpKmsConfig, ServiceAccountLogin}; +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(), "gcp-kms"); + kind + } + other => panic!("not a backend error: {other:?}"), + } +} + +#[tokio::test] +async fn gcp_kms_meets_the_contract() { + let emulator = KmsEmulator::new(); + contract::run(|| fixture_client(emulator.clone()), KEY).await; +} + +#[tokio::test] +async fn gcp_kms_meets_the_contract_with_service_account_credentials() { + let emulator = KmsEmulator::new(); + contract::run( + || { + GcpKms::new( + GcpKmsConfig::new(), + emulator.clone(), + CachedToken::new(ServiceAccountLogin::new( + test_only_service_account(), + emulator.clone(), + )), + ) + }, + KEY, + ) + .await; + // Each client caches its own token. + assert!(emulator.exchanges() >= 1); +} + +#[tokio::test] +async fn a_wrapped_key_opens_only_under_its_own_aad() { + let emulator = KmsEmulator::new(); + let kms = fixture_client(emulator.clone()); + let id = Uuid::from_u128(1); + let aad = record_key_aad(id, KeyVendor::Gcp, 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] + ); + let other = record_key_aad(Uuid::from_u128(2), KeyVendor::Gcp, KeyMode::Derived); + let error = kms.unwrap(KEY, &wrapped, &other).await.unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::InvalidCiphertext); + // The associated data travels whole. + let requests = emulator.requests(); + let (_, path, body) = &requests[0]; + assert_eq!(path, &format!("{KEY}:encrypt")); + assert_eq!( + body["additionalAuthenticatedData"], + ::encode_string(&aad) + ); +} + +#[tokio::test] +async fn rotation_needs_no_change_to_the_key_reference() { + let emulator = KmsEmulator::new(); + let kms = fixture_client(emulator.clone()); + let old = kms + .wrap(KEY, &Protected::new([5u8; 32]), b"aad") + .await + .unwrap(); + assert_eq!(wrapped_key_version(&old), Some(1)); + emulator.rotate(KEY); + let new = kms + .wrap(KEY, &Protected::new([6u8; 32]), b"aad") + .await + .unwrap(); + assert_eq!(wrapped_key_version(&new), Some(2)); + assert_eq!( + kms.unwrap(KEY, &old, b"aad").await.unwrap().risky_ref(), + &[5u8; 32] + ); + assert_eq!( + kms.unwrap(KEY, &new, b"aad").await.unwrap().risky_ref(), + &[6u8; 32] + ); +} + +#[tokio::test] +async fn a_missing_key_is_not_found() { + let emulator = KmsEmulator::new(); + let kms = fixture_client(emulator); + let missing = "projects/test-only-project/locations/global/keyRings/r/cryptoKeys/missing"; + let error = kms + .wrap(missing, &Protected::new([1u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::NotFound); +} + +/// Seal a value under `record`, then rewrap the record to `new_ref` and +/// open the value through the rewrapped record. +async fn rewrap_and_open(emulator: &KmsEmulator, record: KeysetRecord, new_ref: &str) { + 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(); + emulator.rotate(KEY); + let rewrapped = record + .rewrap(&fixture_client(emulator.clone()), new_ref) + .await + .unwrap(); + assert_eq!(wrapped_key_version(rewrapped.wrapped_key()), Some(2)); + 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_rewraps_to_a_new_version_and_keeps_old_values() { + for new_ref in [KEY.to_owned(), format!("{KEY}/cryptoKeyVersions/2")] { + let emulator = KmsEmulator::new(); + let record = KeysetRecord::create( + &fixture_client(emulator.clone()), + KEY, + KeyMode::Derived, + None, + ) + .await + .unwrap(); + rewrap_and_open(&emulator, record, &new_ref).await; + } +} + +#[tokio::test] +async fn a_record_may_rewrap_to_another_crypto_key() { + let emulator = KmsEmulator::new(); + let other = KEY.replace("fixture-key", "other-key"); + emulator.add_key(&other); + let kms = fixture_client(emulator.clone()); + let record = KeysetRecord::create(&kms, 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-gcp/tests/fixtures.rs b/packages/stack-kms-gcp/tests/fixtures.rs new file mode 100644 index 000000000..50cb1853c --- /dev/null +++ b/packages/stack-kms-gcp/tests/fixtures.rs @@ -0,0 +1,540 @@ +//! Each Cloud KMS call, each credential exchange and each error mapping, +//! against fixed HTTP exchanges (`tests/fixtures/*.json`). +//! +//! Google publishes no Cloud KMS emulator, so no fixture was recorded from +//! Google Cloud. Every response is written by hand in the documented +//! Cloud KMS and OAuth 2.0 shapes. Every request side was computed by a +//! separate script, independently of this client (base64, CRC32C, the +//! associated data frame, and the JWT signed by `openssl dgst -sha256 +//! -sign`), and the client must send exactly those bytes. The key that +//! signs the JWT is a TEST-ONLY RSA key generated for these tests +//! (`tests/fixtures/test_only_rsa_key.pkcs8.b64`); it is no one's service +//! account key. `tests/live.rs` runs against real Cloud KMS. + +mod common; + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use base64ct::{Base64, Encoding}; +use common::{ + fixture_client, issued_at, load, replay, test_only_key_pem, test_only_service_account, + test_only_service_account_json, Fixture, FixtureResponse, KmsEmulator, Replay, EMAIL, KEY, + KEY_ID, +}; +use serde::Deserialize; +use stack_kms::{BackendErrorKind, Error, KeyMode, KeyVendor}; +use stack_kms_gcp::crc32c::crc32c; +use stack_kms_gcp::{ + compact_ciphertext, wrapped_key_version, AccessToken, AuthError, CachedToken, Clock, + DefaultCredentials, EnvLookup, GcpKms, GcpKmsConfig, MetadataServerLogin, ServiceAccountKey, + ServiceAccountLogin, TokenProvider, CLOUD_KMS_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::Gcp, KeyMode::Derived) +} + +fn kind_of(error: Error) -> BackendErrorKind { + match error { + Error::Backend { backend, kind, .. } => { + assert_eq!(backend.name(), "gcp-kms"); + kind + } + other => panic!("not a backend error: {other:?}"), + } +} + +/// The stored wrapped key of a fixture's encrypt response. +fn wrapped_of(name: &str) -> Vec { + let fixtures: Vec = load(name); + let body = &fixtures[0].response.body; + let ciphertext = Base64::decode_vec(body["ciphertext"].as_str().unwrap()).unwrap(); + let version = body["name"] + .as_str() + .unwrap() + .rsplit('/') + .next() + .unwrap() + .parse() + .unwrap(); + compact_ciphertext(version, &ciphertext) +} + +fn fixed_clock() -> Clock { + Arc::new(issued_at) +} + +#[tokio::test] +async fn encrypt_and_decrypt() { + let kms = fixture_client(replay("encrypt.json")); + let wrapped = kms + .wrap(KEY, &Protected::new([0x42u8; 32]), &data_aad(0)) + .await + .unwrap(); + assert_eq!(wrapped, wrapped_of("encrypt.json")); + assert_eq!(wrapped_key_version(&wrapped), Some(3)); + assert!(kms.transport().is_done()); + + let kms = fixture_client(replay("decrypt.json")); + let opened = kms.unwrap(KEY, &wrapped, &data_aad(0)).await.unwrap(); + assert_eq!(opened.risky_ref(), &[0x42u8; 32]); + assert!(kms.transport().is_done()); +} + +#[tokio::test] +async fn a_version_name_pins_encrypt_and_decrypt_uses_the_key() { + let pinned = format!("{KEY}/cryptoKeyVersions/2"); + let kms = fixture_client(replay("encrypt_pinned_version.json")); + let wrapped = kms + .wrap(&pinned, &Protected::new([0x43u8; 32]), &data_aad(1)) + .await + .unwrap(); + assert_eq!(wrapped, wrapped_of("encrypt_pinned_version.json")); + assert_eq!(wrapped_key_version(&wrapped), Some(2)); + + // Decrypt names the crypto key, not the version, with either key_ref. + for key_ref in [KEY, pinned.as_str()] { + let kms = fixture_client(replay("decrypt_old_version.json")); + let opened = kms.unwrap(key_ref, &wrapped, &data_aad(1)).await.unwrap(); + assert_eq!(opened.risky_ref(), &[0x43u8; 32]); + assert!(kms.transport().is_done()); + } +} + +#[test] +fn the_jwt_assertion_is_the_openssl_known_answer() { + let expected = std::fs::read_to_string(format!( + "{}/tests/fixtures/test_only_jwt_assertion.txt", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(); + let key = test_only_service_account(); + let assertion = key.assertion(CLOUD_KMS_SCOPE, issued_at()).unwrap(); + assert_eq!(assertion.as_str(), expected.trim()); + // And it verifies with the public key. + let (header, claims) = common::verify_jwt(&assertion).unwrap(); + assert_eq!(header["kid"], KEY_ID); + assert_eq!(claims["iss"], EMAIL); + assert_eq!(claims["iat"], 1_793_880_000u64); + assert_eq!(claims["exp"], 1_793_883_600u64); +} + +#[tokio::test] +async fn a_service_account_key_is_exchanged_for_a_token() { + let login = ServiceAccountLogin::new( + test_only_service_account(), + replay("service_account_token.json"), + ) + .with_clock(fixed_clock()); + let token = login.token().await.unwrap(); + assert_eq!(token.token(), "fixture-service-account-token"); + assert_eq!(token.expires_in(), Some(Duration::from_secs(3599))); + assert!(login.transport().is_done()); +} + +#[tokio::test] +async fn the_metadata_server_gives_a_token() { + let login = MetadataServerLogin::new(replay("metadata_token.json")); + let token = login.token().await.unwrap(); + assert_eq!(token.token(), "fixture-metadata-token"); + assert_eq!(token.expires_in(), Some(Duration::from_secs(3420))); + assert!(login.transport().is_done()); +} + +#[tokio::test] +async fn a_refused_exchange_is_access_denied_and_says_why() { + let login = ServiceAccountLogin::new( + test_only_service_account(), + Replay::new().answer(VendorResponse::new( + 400, + br#"{"error":"invalid_grant","error_description":"Invalid JWT Signature."}"#.to_vec(), + )), + ); + let error = login.token().await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + assert!(error.to_string().contains("Invalid JWT Signature.")); + assert!(!error.to_string().contains("PRIVATE KEY")); +} + +#[test] +fn a_service_account_key_file_is_checked() { + let key = test_only_service_account(); + assert_eq!(key.client_email(), EMAIL); + assert_eq!(key.private_key_id(), Some(KEY_ID)); + assert_eq!(key.token_uri(), "https://oauth2.googleapis.com/token"); + let debug = format!("{key:?}"); + assert!(debug.contains(EMAIL)); + assert!(!debug.contains("PRIVATE KEY")); + + let pem = test_only_key_pem(); + let file = |fields: serde_json::Value| serde_json::to_vec(&fields).unwrap(); + for (json, says) in [ + (b"not json".to_vec(), "not JSON"), + ( + file(serde_json::json!({ "type": "authorized_user", "refresh_token": "x" })), + "\"authorized_user\" is not supported", + ), + ( + file(serde_json::json!({ "client_email": EMAIL, "private_key": pem })), + "no \"type\"", + ), + ( + file(serde_json::json!({ "type": "service_account", "private_key": pem })), + "no client_email", + ), + ( + file(serde_json::json!({ "type": "service_account", "client_email": EMAIL })), + "no private_key", + ), + ( + file(serde_json::json!({ + "type": "service_account", "client_email": EMAIL, "private_key": "nope" + })), + "not an RSA PKCS #8 PEM key", + ), + ( + file(serde_json::json!({ + "type": "service_account", "client_email": EMAIL, "private_key": pem, + "token_uri": "http://oauth2.googleapis.com/token" + })), + "not an HTTPS URL", + ), + ] { + let error = ServiceAccountKey::from_json(&json).unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + assert!(error.to_string().contains(says), "{error}"); + assert!(!error.to_string().contains("PRIVATE KEY")); + } + + // No token_uri means Google's. + let key = ServiceAccountKey::from_json(&file(serde_json::json!({ + "type": "service_account", "client_email": EMAIL, "private_key": pem + }))) + .unwrap(); + assert_eq!(key.token_uri(), "https://oauth2.googleapis.com/token"); + assert_eq!(key.private_key_id(), None); +} + +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 path = std::env::temp_dir().join(format!("stack-kms-gcp-sa-{}.json", Uuid::new_v4())); + std::fs::write(&path, test_only_service_account_json()).unwrap(); + let path_text = path.to_str().unwrap(); + + let credentials = DefaultCredentials::from_env_lookup( + KmsEmulator::new(), + &env(&[ + ("GOOGLE_OAUTH_ACCESS_TOKEN", "static-token"), + ("GOOGLE_APPLICATION_CREDENTIALS", path_text), + ]), + ) + .unwrap(); + assert!(matches!(credentials, DefaultCredentials::Token(_))); + assert_eq!(credentials.token().await.unwrap().token(), "static-token"); + + let emulator = KmsEmulator::new(); + let credentials = DefaultCredentials::from_env_lookup( + emulator.clone(), + &env(&[("GOOGLE_APPLICATION_CREDENTIALS", path_text)]), + ) + .unwrap(); + assert!(matches!(credentials, DefaultCredentials::ServiceAccount(_))); + let token = credentials.token().await.unwrap(); + assert_eq!(token.token(), "fixture-exchanged-token-1"); + // Cached. + let _ = credentials.token().await.unwrap(); + assert_eq!(emulator.exchanges(), 1); + + let emulator = KmsEmulator::new(); + let credentials = DefaultCredentials::from_env_lookup(emulator.clone(), &env(&[])).unwrap(); + assert!(matches!(credentials, DefaultCredentials::MetadataServer(_))); + assert_eq!( + credentials.token().await.unwrap().token(), + "fixture-metadata-token-1" + ); + + let error = DefaultCredentials::from_env_lookup( + KmsEmulator::new(), + &env(&[("GCE_METADATA_HOST", "evil/path")]), + ) + .unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + + std::fs::remove_file(&path).unwrap(); + let error = DefaultCredentials::from_env_lookup( + KmsEmulator::new(), + &env(&[("GOOGLE_APPLICATION_CREDENTIALS", path_text)]), + ) + .unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + assert!(error.to_string().contains("cannot read")); +} + +#[test] +fn the_metadata_host_comes_from_the_environment() { + let login = + MetadataServerLogin::from_env_lookup((), &env(&[("GCE_METADATA_HOST", "127.0.0.1:8080")])) + .unwrap(); + assert_eq!( + login.url(), + "http://127.0.0.1:8080/computeMetadata/v1/instance/service-accounts/default/token" + ); +} + +#[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 = wrapped_of("encrypt.json"); + for case in cases { + let kms = fixture_client(Replay::new().answer(case.response.to_response())); + let error = kms.unwrap(KEY, &wrapped, &data_aad(0)).await.unwrap_err(); + let message = format!("{error:?}"); + assert_eq!( + format!("{:?}", kind_of(error)), + case.kind, + "{}: {message}", + case.name + ); + } +} + +/// A transport that never gets a response. +struct Down; + +impl VendorTransport for Down { + async fn send(&self, _request: VendorRequest) -> Result { + Err(TransportError("connection refused".into())) + } + + async fn sleep(&self, _duration: Duration) {} +} + +#[tokio::test] +async fn no_response_is_unavailable() { + let error = fixture_client(Down) + .unwrap(KEY, &wrapped_of("encrypt.json"), &data_aad(0)) + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Unavailable); + let error = MetadataServerLogin::new(Down).token().await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::Unavailable); +} + +struct NoToken; + +impl TokenProvider for NoToken { + async fn token(&self) -> Result { + Err(AuthError::access_denied("none configured")) + } +} + +#[tokio::test] +async fn no_token_is_access_denied_and_sends_nothing() { + let kms = GcpKms::new(GcpKmsConfig::new(), Replay::new(), NoToken); + let error = kms + .wrap(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_name_is_refused_before_any_call() { + let emulator = KmsEmulator::new(); + let kms = fixture_client(emulator.clone()); + for bad in [ + "", + "key", + "projects/p/locations/l/keyRings/r", + "projects/p/locations/l/keyRings/r/cryptoKeys/k:decrypt", + "projects/p/locations/l/keyRings/../cryptoKeys/k", + "projects/p/locations/l/keyRings/r/cryptoKeys/k?alt=media", + ] { + 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, &[1, 0, 0, 0, 1, 9], 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 = KmsEmulator::new(); + let kms = fixture_client(emulator.clone()); + for bad in [&b""[..], &[1, 0, 0, 0, 3], &[2, 0, 0, 0, 1, 7], b"CiQA"] { + let error = kms.unwrap(KEY, bad, b"a").await.unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::InvalidCiphertext); + } + assert!(emulator.requests().is_empty()); +} + +fn encrypt_response(fields: serde_json::Value) -> VendorResponse { + VendorResponse::new(200, fields.to_string().into_bytes()) +} + +#[tokio::test] +async fn an_encrypt_response_that_cannot_be_trusted_is_unavailable() { + let ciphertext = b"some ciphertext"; + let good = serde_json::json!({ + "name": format!("{KEY}/cryptoKeyVersions/1"), + "ciphertext": Base64::encode_string(ciphertext), + "ciphertextCrc32c": crc32c(ciphertext).to_string(), + "verifiedPlaintextCrc32c": true, + "verifiedAdditionalAuthenticatedDataCrc32c": true, + }); + // The good one is accepted, with the CRC32C as a string or a number. + let mut as_number = good.clone(); + as_number["ciphertextCrc32c"] = serde_json::json!(crc32c(ciphertext)); + for body in [good.clone(), as_number] { + let kms = fixture_client(Replay::new().answer(encrypt_response(body))); + let wrapped = kms + .wrap(KEY, &Protected::new([1u8; 32]), b"a") + .await + .unwrap(); + assert_eq!(wrapped, compact_ciphertext(1, ciphertext)); + } + let mut bad_bodies = Vec::new(); + for (field, value) in [ + ("ciphertextCrc32c", serde_json::json!("1")), + ("ciphertextCrc32c", serde_json::Value::Null), + ("verifiedPlaintextCrc32c", serde_json::json!(false)), + ( + "verifiedAdditionalAuthenticatedDataCrc32c", + serde_json::Value::Null, + ), + ("ciphertext", serde_json::json!("not base64!")), + ("ciphertext", serde_json::json!("")), + ("ciphertext", serde_json::Value::Null), + ("name", serde_json::Value::Null), + ("name", serde_json::json!(KEY)), + ( + "name", + serde_json::json!( + "projects/p/locations/global/keyRings/r/cryptoKeys/other/cryptoKeyVersions/1" + ), + ), + ] { + let mut body = good.clone(); + body[field] = value; + bad_bodies.push(body.to_string().into_bytes()); + } + bad_bodies.push(b"not json".to_vec()); + for body in bad_bodies { + let kms = fixture_client(Replay::new().answer(VendorResponse::new(200, body.clone()))); + let error = kms + .wrap(KEY, &Protected::new([1u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!( + kind_of(error), + BackendErrorKind::Unavailable, + "{}", + String::from_utf8_lossy(&body) + ); + } +} + +#[tokio::test] +async fn a_decrypt_response_that_cannot_be_trusted_is_refused() { + let wrapped = wrapped_of("encrypt.json"); + let key = [7u8; 32]; + let short = [7u8; 16]; + for (body, kind) in [ + ( + serde_json::json!({ "plaintext": Base64::encode_string(&key), "plaintextCrc32c": "1" }), + BackendErrorKind::Unavailable, + ), + ( + serde_json::json!({ "plaintext": Base64::encode_string(&key) }), + BackendErrorKind::Unavailable, + ), + ( + serde_json::json!({ "plaintextCrc32c": "0" }), + BackendErrorKind::Unavailable, + ), + ( + serde_json::json!({ "plaintext": "not base64!", "plaintextCrc32c": "0" }), + BackendErrorKind::Unavailable, + ), + ( + serde_json::json!({ + "plaintext": Base64::encode_string(&short), + "plaintextCrc32c": crc32c(&short).to_string(), + }), + BackendErrorKind::InvalidCiphertext, + ), + ] { + let kms = fixture_client(Replay::new().answer(encrypt_response(body.clone()))); + let error = kms.unwrap(KEY, &wrapped, &data_aad(0)).await.unwrap_err(); + assert_eq!(kind_of(error), kind, "{body}"); + } +} + +#[tokio::test] +async fn a_401_drops_the_cached_token() { + let emulator = KmsEmulator::new(); + let kms = GcpKms::new( + GcpKmsConfig::new(), + emulator.clone(), + CachedToken::new(ServiceAccountLogin::new( + test_only_service_account(), + emulator.clone(), + )), + ); + let wrapped = kms + .wrap(KEY, &Protected::new([2u8; 32]), b"a") + .await + .unwrap(); + assert_eq!(emulator.exchanges(), 1); + emulator.revoke_tokens(); + let error = kms.unwrap(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(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_gcp_and_sleeps_on_its_transport() { + let emulator = KmsEmulator::new(); + let kms = fixture_client(emulator.clone()); + assert_eq!(kms.vendor(), KeyVendor::Gcp); + kms.sleep(Duration::from_millis(50)).await; + assert_eq!(emulator.sleeps(), [Duration::from_millis(50)]); +} diff --git a/packages/stack-kms-gcp/tests/fixtures/decrypt.json b/packages/stack-kms-gcp/tests/fixtures/decrypt.json new file mode 100644 index 000000000..65e9d5e68 --- /dev/null +++ b/packages/stack-kms-gcp/tests/fixtures/decrypt.json @@ -0,0 +1,39 @@ +[ + { + "request": { + "method": "POST", + "url": "https://cloudkms.googleapis.com/v1/projects/test-only-project/locations/global/keyRings/cipherstash-fixture/cryptoKeys/fixture-key:decrypt", + "headers": [ + [ + "authorization", + "Bearer fixture-access-token" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "ciphertext": "CiQAEmyBUPjIK2G75Uos0XBTQANVyK01UXZ3Jn9jPlVg64fGByFw11jlNY1xc2CCm9H5sWA7NVtff+N10aq9ynog3jga0HlcanMRFECWVec4zyjit5k0fMesDWSCdGxGFZQSHHCOhVW+", + "additionalAuthenticatedData": "BQAAAAAAAAAlAAAAAAAAAHN0YWNrLWttcy12ZW5kb3Iva2V5c2V0LXJlY29yZC1rZXkvdjEIAAAAAAAAAHJvb3Qta2V5EAAAAAAAAAAPHi08S1ppeIeWpbTD0uHwAQAAAAAAAAACAQAAAAAAAAAC", + "ciphertextCrc32c": "222503263", + "additionalAuthenticatedDataCrc32c": "3523739393" + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "plaintext": "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=", + "plaintextCrc32c": "2134019127", + "usedPrimary": true, + "protectionLevel": "SOFTWARE" + } + } + } +] diff --git a/packages/stack-kms-gcp/tests/fixtures/decrypt_old_version.json b/packages/stack-kms-gcp/tests/fixtures/decrypt_old_version.json new file mode 100644 index 000000000..859629556 --- /dev/null +++ b/packages/stack-kms-gcp/tests/fixtures/decrypt_old_version.json @@ -0,0 +1,39 @@ +[ + { + "request": { + "method": "POST", + "url": "https://cloudkms.googleapis.com/v1/projects/test-only-project/locations/global/keyRings/cipherstash-fixture/cryptoKeys/fixture-key:decrypt", + "headers": [ + [ + "authorization", + "Bearer fixture-access-token" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "ciphertext": "CiQAJGkadvNFiRYxQukGpPXsH5mn+UILBKXRpfGmC4+IrCQ/q1wYG9KKCbZDl992riv68erBgpebX9t6NChYAE82r0cYIwZ+w5BcZ963iIyCwVmuqBRxcsEdBT4j1zKcVVun5CBPLuCO", + "additionalAuthenticatedData": "BQAAAAAAAAAlAAAAAAAAAHN0YWNrLWttcy12ZW5kb3Iva2V5c2V0LXJlY29yZC1rZXkvdjEIAAAAAAAAAHJvb3Qta2V5EAAAAAAAAAAPHi08S1ppeIeWpbTD0uHxAQAAAAAAAAACAQAAAAAAAAAC", + "ciphertextCrc32c": "2110219672", + "additionalAuthenticatedDataCrc32c": "1799140326" + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "plaintext": "Q0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0M=", + "plaintextCrc32c": "1618493874", + "usedPrimary": false, + "protectionLevel": "SOFTWARE" + } + } + } +] diff --git a/packages/stack-kms-gcp/tests/fixtures/encrypt.json b/packages/stack-kms-gcp/tests/fixtures/encrypt.json new file mode 100644 index 000000000..486791084 --- /dev/null +++ b/packages/stack-kms-gcp/tests/fixtures/encrypt.json @@ -0,0 +1,41 @@ +[ + { + "request": { + "method": "POST", + "url": "https://cloudkms.googleapis.com/v1/projects/test-only-project/locations/global/keyRings/cipherstash-fixture/cryptoKeys/fixture-key:encrypt", + "headers": [ + [ + "authorization", + "Bearer fixture-access-token" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "plaintext": "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=", + "additionalAuthenticatedData": "BQAAAAAAAAAlAAAAAAAAAHN0YWNrLWttcy12ZW5kb3Iva2V5c2V0LXJlY29yZC1rZXkvdjEIAAAAAAAAAHJvb3Qta2V5EAAAAAAAAAAPHi08S1ppeIeWpbTD0uHwAQAAAAAAAAACAQAAAAAAAAAC", + "plaintextCrc32c": "2134019127", + "additionalAuthenticatedDataCrc32c": "3523739393" + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "name": "projects/test-only-project/locations/global/keyRings/cipherstash-fixture/cryptoKeys/fixture-key/cryptoKeyVersions/3", + "ciphertext": "CiQAEmyBUPjIK2G75Uos0XBTQANVyK01UXZ3Jn9jPlVg64fGByFw11jlNY1xc2CCm9H5sWA7NVtff+N10aq9ynog3jga0HlcanMRFECWVec4zyjit5k0fMesDWSCdGxGFZQSHHCOhVW+", + "ciphertextCrc32c": "222503263", + "verifiedPlaintextCrc32c": true, + "verifiedAdditionalAuthenticatedDataCrc32c": true, + "protectionLevel": "SOFTWARE" + } + } + } +] diff --git a/packages/stack-kms-gcp/tests/fixtures/encrypt_pinned_version.json b/packages/stack-kms-gcp/tests/fixtures/encrypt_pinned_version.json new file mode 100644 index 000000000..cb1eb70b7 --- /dev/null +++ b/packages/stack-kms-gcp/tests/fixtures/encrypt_pinned_version.json @@ -0,0 +1,41 @@ +[ + { + "request": { + "method": "POST", + "url": "https://cloudkms.googleapis.com/v1/projects/test-only-project/locations/global/keyRings/cipherstash-fixture/cryptoKeys/fixture-key/cryptoKeyVersions/2:encrypt", + "headers": [ + [ + "authorization", + "Bearer fixture-access-token" + ], + [ + "content-type", + "application/json" + ] + ], + "body": { + "plaintext": "Q0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0M=", + "additionalAuthenticatedData": "BQAAAAAAAAAlAAAAAAAAAHN0YWNrLWttcy12ZW5kb3Iva2V5c2V0LXJlY29yZC1rZXkvdjEIAAAAAAAAAHJvb3Qta2V5EAAAAAAAAAAPHi08S1ppeIeWpbTD0uHxAQAAAAAAAAACAQAAAAAAAAAC", + "plaintextCrc32c": "1618493874", + "additionalAuthenticatedDataCrc32c": "1799140326" + } + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "name": "projects/test-only-project/locations/global/keyRings/cipherstash-fixture/cryptoKeys/fixture-key/cryptoKeyVersions/2", + "ciphertext": "CiQAJGkadvNFiRYxQukGpPXsH5mn+UILBKXRpfGmC4+IrCQ/q1wYG9KKCbZDl992riv68erBgpebX9t6NChYAE82r0cYIwZ+w5BcZ963iIyCwVmuqBRxcsEdBT4j1zKcVVun5CBPLuCO", + "ciphertextCrc32c": "2110219672", + "verifiedPlaintextCrc32c": true, + "verifiedAdditionalAuthenticatedDataCrc32c": true, + "protectionLevel": "SOFTWARE" + } + } + } +] diff --git a/packages/stack-kms-gcp/tests/fixtures/errors.json b/packages/stack-kms-gcp/tests/fixtures/errors.json new file mode 100644 index 000000000..828e164a2 --- /dev/null +++ b/packages/stack-kms-gcp/tests/fixtures/errors.json @@ -0,0 +1,267 @@ +[ + { + "name": "permission denied on the key", + "kind": "AccessDenied", + "response": { + "status": 403, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "error": { + "code": 403, + "message": "Permission 'cloudkms.cryptoKeyVersions.useToDecrypt' denied on resource 'projects/test-only-project/locations/global/keyRings/cipherstash-fixture/cryptoKeys/fixture-key' (or it may not exist).", + "status": "PERMISSION_DENIED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "IAM_PERMISSION_DENIED", + "domain": "cloudkms.googleapis.com", + "metadata": { + "permission": "cloudkms.cryptoKeyVersions.useToDecrypt", + "resource": "projects/test-only-project/locations/global/keyRings/cipherstash-fixture/cryptoKeys/fixture-key" + } + } + ] + } + } + } + }, + { + "name": "an expired access token", + "kind": "AccessDenied", + "response": { + "status": 401, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "error": { + "code": 401, + "message": "Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential.", + "status": "UNAUTHENTICATED" + } + } + } + }, + { + "name": "a key that does not exist", + "kind": "NotFound", + "response": { + "status": 404, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "error": { + "code": 404, + "message": "projects/test-only-project/locations/global/keyRings/cipherstash-fixture/cryptoKeys/fixture-key not found.", + "status": "NOT_FOUND" + } + } + } + }, + { + "name": "a quota", + "kind": "Throttled", + "response": { + "status": 429, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "error": { + "code": 429, + "message": "Quota exceeded for quota metric 'Cryptographic requests' and limit 'Cryptographic requests per minute' of service 'cloudkms.googleapis.com'.", + "status": "RESOURCE_EXHAUSTED" + } + } + } + }, + { + "name": "the service is unavailable", + "kind": "Unavailable", + "response": { + "status": 503, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "error": { + "code": 503, + "message": "The service is currently unavailable.", + "status": "UNAVAILABLE" + } + } + } + }, + { + "name": "a deadline", + "kind": "Unavailable", + "response": { + "status": 504, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "error": { + "code": 504, + "message": "Deadline expired before operation could complete.", + "status": "DEADLINE_EXCEEDED" + } + } + } + }, + { + "name": "an internal error", + "kind": "Unavailable", + "response": { + "status": 500, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "error": { + "code": 500, + "message": "Internal error encountered.", + "status": "INTERNAL" + } + } + } + }, + { + "name": "a disabled key version", + "kind": "Unavailable", + "response": { + "status": 400, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "error": { + "code": 400, + "message": "projects/test-only-project/locations/global/keyRings/cipherstash-fixture/cryptoKeys/fixture-key/cryptoKeyVersions/3 is not enabled, current state is: DISABLED.", + "status": "FAILED_PRECONDITION" + } + } + } + }, + { + "name": "a damaged ciphertext or other associated data", + "kind": "InvalidCiphertext", + "response": { + "status": 400, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "error": { + "code": 400, + "message": "Decryption failed: the ciphertext is invalid.", + "status": "INVALID_ARGUMENT" + } + } + } + }, + { + "name": "a ciphertext of another key", + "kind": "InvalidCiphertext", + "response": { + "status": 400, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "error": { + "code": 400, + "message": "Decryption failed: verify that 'name' refers to the correct CryptoKey.", + "status": "INVALID_ARGUMENT" + } + } + } + }, + { + "name": "a CRC32C that does not match", + "kind": "Unavailable", + "response": { + "status": 400, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "error": { + "code": 400, + "message": "The checksum in field ciphertext_crc32c did not match the data in field ciphertext.", + "status": "INVALID_ARGUMENT" + } + } + } + }, + { + "name": "another invalid argument", + "kind": "AccessDenied", + "response": { + "status": 400, + "headers": [ + [ + "content-type", + "application/json; charset=UTF-8" + ] + ], + "body": { + "error": { + "code": 400, + "message": "Ciphertext must be at most 65536 bytes.", + "status": "INVALID_ARGUMENT" + } + } + } + }, + { + "name": "a gateway error with no JSON", + "kind": "Unavailable", + "response": { + "status": 502, + "headers": [ + [ + "content-type", + "text/html; charset=UTF-8" + ] + ], + "body": "Error 502 (Server Error)!!1" + } + } +] diff --git a/packages/stack-kms-gcp/tests/fixtures/metadata_token.json b/packages/stack-kms-gcp/tests/fixtures/metadata_token.json new file mode 100644 index 000000000..a31bbd446 --- /dev/null +++ b/packages/stack-kms-gcp/tests/fixtures/metadata_token.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "GET", + "url": "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", + "headers": [ + [ + "metadata-flavor", + "Google" + ] + ], + "body": null + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json" + ], + [ + "metadata-flavor", + "Google" + ] + ], + "body": { + "access_token": "fixture-metadata-token", + "expires_in": 3420, + "token_type": "Bearer" + } + } + } +] diff --git a/packages/stack-kms-gcp/tests/fixtures/service_account_token.json b/packages/stack-kms-gcp/tests/fixtures/service_account_token.json new file mode 100644 index 000000000..02e467c80 --- /dev/null +++ b/packages/stack-kms-gcp/tests/fixtures/service_account_token.json @@ -0,0 +1,29 @@ +[ + { + "request": { + "method": "POST", + "url": "https://oauth2.googleapis.com/token", + "headers": [ + [ + "content-type", + "application/x-www-form-urlencoded" + ] + ], + "body": "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qtb25seS1maXh0dXJlLWtleS1pZCIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdGFjay1rbXMtZ2NwLWZpeHR1cmVAdGVzdC1vbmx5LXByb2plY3QuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLCJzY29wZSI6Imh0dHBzOi8vd3d3Lmdvb2dsZWFwaXMuY29tL2F1dGgvY2xvdWRrbXMiLCJhdWQiOiJodHRwczovL29hdXRoMi5nb29nbGVhcGlzLmNvbS90b2tlbiIsImlhdCI6MTc5Mzg4MDAwMCwiZXhwIjoxNzkzODgzNjAwfQ.XJBEwxYG-9iqjRMPihdEoOehSRmBqPGO-gu4f6pFGpqCkWGEOh2VgmwXwrquAx7JqMdl-ctShZML8bFymQ2V_RYuAuOxmQy6kqDOjmVrCFre0mxhAJNDQWuxTBIbABftcay_I7t5m0rX3gpbmM5NnfNJw23JAgbu4EUk2RBaHutGXF8O8YUgjuEUOFr5I_3xmVSw5FNe_dAq9JmV3xHJuI2TvvTfBFJ-KY4xTccHZbdb7YH4y6OQuyq1U679rjOZ0bdqapx3ayvedkL5nQ4zS43Xh8kLulBERJ_X-V6Gupjktq53DwoXOxDSdY5aQBKSP0B6_bj4qIOHQzceISNbRw" + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/json; charset=utf-8" + ] + ], + "body": { + "access_token": "fixture-service-account-token", + "expires_in": 3599, + "token_type": "Bearer" + } + } + } +] diff --git a/packages/stack-kms-gcp/tests/fixtures/test_only_jwt_assertion.txt b/packages/stack-kms-gcp/tests/fixtures/test_only_jwt_assertion.txt new file mode 100644 index 000000000..4692211a2 --- /dev/null +++ b/packages/stack-kms-gcp/tests/fixtures/test_only_jwt_assertion.txt @@ -0,0 +1 @@ +eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qtb25seS1maXh0dXJlLWtleS1pZCIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdGFjay1rbXMtZ2NwLWZpeHR1cmVAdGVzdC1vbmx5LXByb2plY3QuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLCJzY29wZSI6Imh0dHBzOi8vd3d3Lmdvb2dsZWFwaXMuY29tL2F1dGgvY2xvdWRrbXMiLCJhdWQiOiJodHRwczovL29hdXRoMi5nb29nbGVhcGlzLmNvbS90b2tlbiIsImlhdCI6MTc5Mzg4MDAwMCwiZXhwIjoxNzkzODgzNjAwfQ.XJBEwxYG-9iqjRMPihdEoOehSRmBqPGO-gu4f6pFGpqCkWGEOh2VgmwXwrquAx7JqMdl-ctShZML8bFymQ2V_RYuAuOxmQy6kqDOjmVrCFre0mxhAJNDQWuxTBIbABftcay_I7t5m0rX3gpbmM5NnfNJw23JAgbu4EUk2RBaHutGXF8O8YUgjuEUOFr5I_3xmVSw5FNe_dAq9JmV3xHJuI2TvvTfBFJ-KY4xTccHZbdb7YH4y6OQuyq1U679rjOZ0bdqapx3ayvedkL5nQ4zS43Xh8kLulBERJ_X-V6Gupjktq53DwoXOxDSdY5aQBKSP0B6_bj4qIOHQzceISNbRw diff --git a/packages/stack-kms-gcp/tests/fixtures/test_only_rsa_key.pkcs8.b64 b/packages/stack-kms-gcp/tests/fixtures/test_only_rsa_key.pkcs8.b64 new file mode 100644 index 000000000..e2a1d8020 --- /dev/null +++ b/packages/stack-kms-gcp/tests/fixtures/test_only_rsa_key.pkcs8.b64 @@ -0,0 +1,22 @@ +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCPqbq3hUOm4E3TomwpjulEakQd +AVec6b2FtTtdhEZIEJ7Zp1AlV8KiDxZbv3GcO+mlh7kGHCCScXFkWt2UpVL9/PwBWB8gFl5+UBcP +wC8DgKEHBT3c319mcPGU35O3yVLrphFfhaRG/xU6VqTstD4WwV3C3ebYCa6SUViIjDDR2t35HPfa +duT5RYs7sa1r7rCcwyxQIGRUtB1NSYEZB0ZLadpu7hPSKpO49Uky0x8t4PolE+qsIfB1gAYaJ9hH +CY45cdRe31pe+NOfU+DmvYWZcLHlsCzIBcFGRChxqHgRf51DGp5Mk2elpa5ruLafDgOeBHvBMV7c +heNwMJJdpMINAgMBAAECggEAOT9d986jeEauzLRPHLplqw0bimFkC9O64aKn/up8ZTA2VK5PCipw +FUsk0je2KpdXUk6ExPIFasXI9UAeRyMgMLUUqOZ4NL2lNvlsTyr6sQ8bW+RTVAYxCm3cXsI/J4AS +ynOZPbLQFTiFuYMYYniquz+fwMzTNeiZx5WhzXvr4luXrbFD6uvkvp4ZKys2klM+2Ki1M1tL3Zew +tm+NvaE2RdOhmY1CZY8vOcaNUmNSJ+AmGgxnWTZLdAXXyh3p6eEBem2Ig9qTzyxxX4k1EMCOe8Tf +EeAC018dTmeLj7kYylms+Yt/pOKnLqyKKEHg/IkmQS0qyRpkejx1EwjEf0LPkwKBgQDFFwQVe6AK +g1sCq6OMfksWs/z4LZNS0S9IjqnmYi52DUhAPWoQDv9M5RFwNbYYfGUSUjybDc/qB6406Dh1AO8A +bcnUgR58J32oT3e9Ouyh7TBYFMapG3uSBghACXc8zAvGbIXTtYZrK4BlMxER8OHzPdZegfSNEFWD +enWV+HzYxwKBgQC6mpPzNGGRhPCvykYnL9Z+omuef134OB3dyUp90bsllXVvWCx0EpgIp7E3WCTW +oRJK4IQBKsLOsXHkBDa4l8ZinF0LXaRnkYK9whqG7EbXgS9ekWK0+PJhMKs/Bm3qGK8vmABq0JbQ +rQYeyu8VEW6XZ0cqSWfx/3eQMFT6zAqCiwKBgCHY5QuncoG7s0qPHYRIQC8C9CPO9+kWIa+tSIJX +RDFj8I+PvIJLgQ68nDUQFFi6LEVsOBpM0fMYJJPTebamVWVWdX34OrsJsSvRkfe+gdsBN/c4gjGS +lbMrNAmDVqXrSO5nvsfv+ci37NH0Zi8VlkhGy2Tpno0HTBhLVJxP02kLAoGBAIwIhnCjZXjLt8dj +1yN/LcsYXgSydjVY/2i5rvIemv/IWjl1hN6SOY99MGtk9Tgv5cdVFxq9GTX9SYegdeQu2cyUpOpr +mn2yX9fBOyD3XO5KXt7r4ZQHofS3jjscuw1K+uSPf/iokhUFUyt2hNZdn411Dp24Oo4LdZE6A/u1 +dUmjAoGBALjrI2/EwDIJ2etEyDeR9+TUsfFxDhKGV5voWcIsh8jj1Q1FivBqSeHXgngZM3SZ9cgp +k5dwf3Eusct+24jv+nJFo6I+hDR4UitYzUG2E105+F/CXA4aX46K3q26IYzvnMGvK158AogkqfI3 +3QgwyO+dn+s/hbLy/d/PCw/YhS1r diff --git a/packages/stack-kms-gcp/tests/live.rs b/packages/stack-kms-gcp/tests/live.rs new file mode 100644 index 000000000..39c7d35cc --- /dev/null +++ b/packages/stack-kms-gcp/tests/live.rs @@ -0,0 +1,107 @@ +//! The Cloud KMS backend against real Google Cloud KMS. +//! +//! Google publishes no Cloud KMS emulator, so this is the only test of the +//! real service. It runs when `STACK_KMS_GCP_LIVE_KEY` names a symmetric +//! `ENCRYPT_DECRYPT` crypto key +//! (`projects/{p}/locations/{l}/keyRings/{r}/cryptoKeys/{k}`) and skips +//! itself otherwise. The credentials come from the environment +//! ([`DefaultCredentials`](stack_kms_gcp::DefaultCredentials)): normally +//! `GOOGLE_APPLICATION_CREDENTIALS`, the path of a service account key file +//! with `roles/cloudkms.cryptoKeyEncrypterDecrypter` on the key. +//! +//! ```text +//! STACK_KMS_GCP_LIVE_KEY=projects/.../cryptoKeys/stack-kms-gcp-live \ +//! GOOGLE_APPLICATION_CREDENTIALS=$HOME/stack-kms-gcp-live.json \ +//! mise run test:live:stack-kms-gcp +//! ``` +//! +//! The opt-in `integration-gcp-kms.yml` workflow runs it in CI. + +use stack_kms::{BackendErrorKind, Error, KeyMode, KeyVendor}; +use stack_kms_gcp::{parse_key_name, wrapped_key_version, GcpKms}; +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() -> Option { + let key = std::env::var("STACK_KMS_GCP_LIVE_KEY") + .ok() + .filter(|key| !key.is_empty())?; + assert!( + matches!(parse_key_name(&key), Some((_, None))), + "STACK_KMS_GCP_LIVE_KEY is not a crypto key name: {key}" + ); + Some(key) +} + +fn kind_of(error: Error) -> BackendErrorKind { + match error { + Error::Backend { backend, kind, .. } => { + assert_eq!(backend.name(), "gcp-kms"); + kind + } + other => panic!("not a backend error: {other:?}"), + } +} + +#[tokio::test] +async fn cloud_kms_meets_the_contract() { + let Some(key) = live_key() else { + eprintln!("skipped: set STACK_KMS_GCP_LIVE_KEY to run against Cloud KMS"); + return; + }; + contract::run(|| GcpKms::from_env().expect("credentials"), &key).await; +} + +#[tokio::test] +async fn cloud_kms_binds_the_associated_data_and_maps_errors() { + let Some(key) = live_key() else { + eprintln!("skipped: set STACK_KMS_GCP_LIVE_KEY to run against Cloud KMS"); + return; + }; + let kms = GcpKms::from_env().expect("credentials"); + let id = Uuid::new_v4(); + let aad = record_key_aad(id, KeyVendor::Gcp, KeyMode::Derived); + let wrapped = kms + .wrap(&key, &Protected::new([0x5Au8; 32]), &aad) + .await + .unwrap(); + assert!(wrapped_key_version(&wrapped).is_some_and(|version| version >= 1)); + 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. + let other = record_key_aad(Uuid::new_v4(), KeyVendor::Gcp, 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. Cloud KMS answers PERMISSION_DENIED, not + // NOT_FOUND, when the caller may not list the key ring, so either is + // right here. + let (name, _) = parse_key_name(&key).unwrap(); + let missing = format!( + "{}/stack-kms-gcp-no-such-key-{}", + name.rsplit_once('/').unwrap().0, + 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 590d34d3b..f5703bbd7 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -19,7 +19,7 @@ # feature), so a crates.io release of `eql-bindings` needs the matching # `stack-encrypt` published first. # -# Three more crates are publishable but STAGED: `stack-kms-vendor`, `stack-kms-aws` and `stack-kms-vault`, the `stack-kms-vendor` version group (the key vendor seam +# 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 # 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 @@ -107,3 +107,10 @@ name = "stack-kms-vault" version_group = "stack-kms-vendor" release = false publish = false + +# Staged: see the header. +[[package]] +name = "stack-kms-gcp" +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 4e45e2bc6..5d952df2f 100644 --- a/scripts/__tests__/cargo-publish-opt-out.test.mjs +++ b/scripts/__tests__/cargo-publish-opt-out.test.mjs @@ -57,7 +57,7 @@ const WORKSPACES = [ // exactly these five: stack-auth and stack-profile, and stack-kms, // stack-encrypt and stack-encrypt-derive (which `eql-bindings`' // `stack-encrypt` feature depends on from crates.io). stack-guest-abi is - // the Go guests' internal ABI and stays unpublished. stack-kms-vendor, stack-kms-aws and stack-kms-vault + // 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 // from releasing until their first version is published by hand (see // release-plz-root-config.test.mjs). @@ -71,6 +71,7 @@ const WORKSPACES = [ 'packages/stack-kms-vendor', 'packages/stack-kms-aws', 'packages/stack-kms-vault', + 'packages/stack-kms-gcp', ]), expects: 'packages/stack-auth', }, diff --git a/scripts/__tests__/crates-ci.test.mjs b/scripts/__tests__/crates-ci.test.mjs index cf436ead3..576892d63 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 eight crate `tasks.toml` files the + * 1. Every mise task the set defines — the nine 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 eight crate task files from the root mise.toml', () => { + it('reads the nine 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-gcp/tasks.toml', 'packages/stack-kms-vault/tasks.toml', 'packages/stack-kms-vendor/tasks.toml', 'packages/stack-profile/tasks.toml', @@ -341,6 +342,7 @@ describe('the scan sees every task mise sees', () => { 'doc:stack-guest-abi', 'doc:stack-kms', 'doc:stack-kms-aws', + 'doc:stack-kms-gcp', 'doc:stack-kms-vault', 'doc:stack-kms-vendor', 'doc:stack-profile', @@ -399,6 +401,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-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:* `${WORKFLOW_DIR}/tests-golang.yml`, // wasm:*, go:test, go:lint diff --git a/scripts/__tests__/release-plz-root-config.test.mjs b/scripts/__tests__/release-plz-root-config.test.mjs index 3c005ab22..1594b3d79 100644 --- a/scripts/__tests__/release-plz-root-config.test.mjs +++ b/scripts/__tests__/release-plz-root-config.test.mjs @@ -47,6 +47,7 @@ const STAGED = { 'stack-kms-vendor': 'stack-kms-vendor', 'stack-kms-aws': 'stack-kms-vendor', 'stack-kms-vault': 'stack-kms-vendor', + 'stack-kms-gcp': '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 1e6af29c5..1e5e247b9 100644 --- a/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs +++ b/scripts/__tests__/workflow-dispatch-job-conditions.test.mjs @@ -68,6 +68,9 @@ const EXPECTED_DISPATCHABLE = [ // The same dry run for the @cipherstash/auth line. '.github/workflows/auth-preflight.yml', '.github/workflows/integration-protect-ffi.yml', + // 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', // 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', @@ -121,6 +124,8 @@ const EXPECTED_FORK_GUARDED_JOBS = [ // The routine token is a repository secret, which forks are not given. '.github/workflows/code-review.yml / request', '.github/workflows/integration-drizzle.yml / integration', + // A repository secret (the Cloud KMS service account key). + '.github/workflows/integration-gcp-kms.yml / live', '.github/workflows/integration-prisma-next.yml / integration', '.github/workflows/integration-protect-ffi.yml / integration', '.github/workflows/integration-supabase.yml / integration', @@ -220,6 +225,9 @@ const PERMISSIVE_NEEDS = { */ const PERMISSIVE_VARS = { CLAUDE_REVIEW_ENABLED: 'true', + // 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', } /**