diff --git a/.github/workflows/tests-crates.yml b/.github/workflows/tests-crates.yml index 6b53ba9af..2a89946d8 100644 --- a/.github/workflows/tests-crates.yml +++ b/.github/workflows/tests-crates.yml @@ -21,6 +21,7 @@ on: - packages/stack-profile/** - packages/stack-kms/** - packages/stack-kms-vendor/** + - packages/stack-kms-aws/** - packages/stack-encrypt/** - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** @@ -46,6 +47,7 @@ on: - packages/stack-profile/** - packages/stack-kms/** - packages/stack-kms-vendor/** + - packages/stack-kms-aws/** - packages/stack-encrypt/** - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** @@ -153,15 +155,45 @@ 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 - # is publishable but staged in release-plz.toml (its - # first version is published by hand); packaging it here keeps it + # resolves the published versions, which lag the tree. stack-kms-vendor and stack-kms-aws + # are publishable but staged in release-plz.toml (their + # first version is published by hand); packaging them here keeps them # publishable meanwhile. No token. # `--allow-dirty` tolerates files earlier steps leave in the tree. Once a # version is on crates.io, cargo warns rather than fails on a dry run of # it. - name: Verify the stack-* crates package cleanly for crates.io - run: cargo publish --locked --dry-run --allow-dirty -p stack-profile -p stack-auth -p stack-kms -p stack-encrypt-derive -p stack-encrypt -p stack-kms-vendor + 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 + + # 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 + # itself for want of an endpoint; this job sets the endpoint, and the mise + # task refuses to run without one, so a skip cannot read as a pass here. + localstack: + name: stack-kms-aws against LocalStack + runs-on: blacksmith-16vcpu-ubuntu-2204 + timeout-minutes: 30 + env: + MISE_ENV: test + STACK_KMS_AWS_LOCALSTACK_ENDPOINT: http://localhost:4566 + services: + localstack: + # Pinned by digest. 4.4 is a community release that needs no auth + # token. + image: localstack/localstack:4.4@sha256:b52c16663c70b7234f217cb993a339b46686e30a1a5d9279cb5feeb2202f837c + ports: + - 4566:4566 + env: + SERVICES: kms + options: >- + --health-cmd "curl -fs http://localhost:4566/_localstack/health" + --health-interval 5s + --health-timeout 5s + --health-retries 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 with: @@ -174,6 +206,8 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + - name: stack-kms-aws against LocalStack + run: mise run test:localstack:stack-kms-aws - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 with: diff --git a/.github/workflows/udeps.yml b/.github/workflows/udeps.yml index 1e51657ee..a89e9d8b3 100644 --- a/.github/workflows/udeps.yml +++ b/.github/workflows/udeps.yml @@ -15,6 +15,7 @@ on: - packages/stack-profile/** - packages/stack-kms/** - packages/stack-kms-vendor/** + - packages/stack-kms-aws/** - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** @@ -35,6 +36,7 @@ on: - packages/stack-profile/** - packages/stack-kms/** - packages/stack-kms-vendor/** + - packages/stack-kms-aws/** - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** diff --git a/AGENTS.md b/AGENTS.md index ec5ecb416..826874348 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`: The key vendor seam (ADR-0013 and ADR-0014 in `packages/stack-encrypt/docs/adr/`). `stack-kms-vendor` is the vendor seam (`KeyVendorBackend`), keyset records, the derived data key mode, the static key backend and the `VendorTransport` HTTP seam (feature `http`: the native reqwest transport). Its `test-support` feature holds the test code every vendor crate shares: the contract suite (`contract`), `FakeVendor`, and the recorded-fixture harness (`replay`: fixture types, `Replay`, `Recording`); a vendor crate's `tests/common/mod.rs` holds only its own emulator, keys and constants. It is a root workspace member, publishable in the `stack-kms-vendor` version group, and staged in `release-plz.toml` (`release = false`) until its first version is published by hand. The `stack-kms-vendor` crate docs hold the capability matrix of every key backend (key lifetime, vendor calls, what the vendor sees and can refuse, credentials, rotation, known limits); change it with the code it describes. TypeScript (`@cipherstash/stack`) cannot use a key vendor backend yet. +- `packages/stack-kms-vendor`, `packages/stack-kms-aws`: The key vendor backends (ADR-0013 and ADR-0014 in `packages/stack-encrypt/docs/adr/`). `stack-kms-vendor` is the vendor seam (`KeyVendorBackend`), keyset records, the derived data key mode, the static key backend and the `VendorTransport` HTTP seam (feature `http`: the native reqwest transport). Its `test-support` feature holds the test code every vendor crate shares: the contract suite (`contract`), `FakeVendor`, and the recorded-fixture harness (`replay`: fixture types, `Replay`, `Recording`); a vendor crate's `tests/common/mod.rs` holds only its own emulator, keys and constants. `stack-kms-aws` is the AWS KMS backend: a hand-written JSON client with SigV4 signing in-crate and the AWS credential providers; its LocalStack test runs in `tests-crates.yml`'s `localstack` job. All two are root workspace members, publishable in the `stack-kms-vendor` version group, and staged in `release-plz.toml` (`release = false`) until their first version is published by hand. The `stack-kms-vendor` crate docs hold the capability matrix of every key backend (key lifetime, vendor calls, what the vendor sees and can refuse, credentials, rotation, known limits); change it with the code it describes. TypeScript (`@cipherstash/stack`) cannot use a key vendor backend yet. - `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 seven stack-* +- **Three Cargo workspaces, not one.** The root workspace (the eight 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 six crate `tasks.toml` files loses its last CI caller. + `mise.toml` or the seven 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 c96f3525c..24a43b94d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3519,6 +3519,28 @@ dependencies = [ "zerokms-protocol", ] +[[package]] +name = "stack-kms-aws" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "base64ct", + "hmac", + "serde", + "serde_json", + "sha2 0.11.0", + "stack-encrypt", + "stack-kms", + "stack-kms-vendor", + "thiserror 1.0.69", + "tokio", + "url", + "uuid", + "vitaminc 0.9.0", + "web-time", + "zeroize", +] + [[package]] name = "stack-kms-vendor" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c75cfe2a5..098f24afa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "packages/stack-profile", "packages/stack-kms", "packages/stack-kms-vendor", + "packages/stack-kms-aws", "packages/stack-encrypt", "packages/stack-encrypt-derive", "packages/stack-guest-abi", diff --git a/SECURITY.md b/SECURITY.md index b71c1aa05..a35774b15 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,9 +38,9 @@ from the stack-* crates), released by `.github/workflows/release-golang.yml` as `languages/golang/vX.Y.Z` tags. All of these are in scope for security reports on the same terms as the npm packages above. -One more Rust crate, **`stack-kms-vendor`** -(`packages/stack-kms-vendor`), is prepared for -crates.io but not yet published. It is in scope on the same terms, and +Two more Rust crates, **`stack-kms-vendor`** and **`stack-kms-aws`** +(`packages/stack-kms-vendor`, `packages/stack-kms-aws`), 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. The five stack-* crates are licensed under the PolyForm Internal Use License diff --git a/biome.json b/biome.json index 435aa70b0..f82e9c9fc 100644 --- a/biome.json +++ b/biome.json @@ -15,6 +15,7 @@ "!packages/stack-encrypt/tests/fixtures/value_kinds.json", "!packages/stack-encrypt/tests/fixtures/stored_rows_v1.json", "!packages/stack-encrypt/tests/fixtures/json_tree_vectors.json", + "!packages/stack-kms-*/tests/fixtures", "!packages/eql/tests/encryption/fixtures/ste_vec_selectors.json", "!packages/eql/tests/encryption/fixtures/v4_terms.json", "!packages/eql/tests/encryption/fixtures/v4_rows.json", diff --git a/mise.toml b/mise.toml index 17ba60f03..869583c33 100644 --- a/mise.toml +++ b/mise.toml @@ -11,6 +11,7 @@ includes = [ "packages/stack-auth/tasks.toml", "packages/stack-kms/tasks.toml", "packages/stack-kms-vendor/tasks.toml", + "packages/stack-kms-aws/tasks.toml", "packages/stack-profile/tasks.toml", "packages/stack-encrypt/tasks.toml", "packages/stack-guest-abi/tasks.toml", @@ -125,6 +126,9 @@ check stack-encrypt --no-default-features # must add nothing here either. check stack-kms-vendor check stack-kms-vendor --features http +# The AWS KMS key vendor backend, default features on: its native transport +# and workload identity credential sources are gated off on wasm32. +check stack-kms-aws # The 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-aws/Cargo.toml b/packages/stack-kms-aws/Cargo.toml new file mode 100644 index 000000000..310519ef1 --- /dev/null +++ b/packages/stack-kms-aws/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "stack-kms-aws" +description = "The AWS KMS key vendor backend for Stack Encrypt: a hand-written AWS KMS client with SigV4 signing, 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"] +# `AwsKms::from_env`: the native transport (`HttpVendorTransport`) and the +# default credential chain over it. Native only; on wasm32 it adds nothing. +http = ["stack-kms-vendor/http"] + +[dependencies] +stack-kms = { path = "../stack-kms", version = "0.2.0", default-features = false } +stack-kms-vendor = { path = "../stack-kms-vendor", version = "0.1.0" } + +vitaminc = { workspace = true, features = ["protected"] } +base64ct = { version = "1.7", features = ["alloc"] } +hmac = "0.13" +sha2 = "0.11" +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"] } + +[package.metadata.docs.rs] +all-features = true diff --git a/packages/stack-kms-aws/LICENSE b/packages/stack-kms-aws/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-kms-aws/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-aws/src/client.rs b/packages/stack-kms-aws/src/client.rs new file mode 100644 index 000000000..cdb7718d3 --- /dev/null +++ b/packages/stack-kms-aws/src/client.rs @@ -0,0 +1,501 @@ +//! The AWS KMS client and its [`KeyVendorBackend`] implementation. + +use std::collections::BTreeMap; +use std::time::Duration; + +use base64ct::{Base64, Base64UrlUnpadded, Encoding}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use stack_kms::{BackendErrorKind, Error, KeyVendor}; +use stack_kms_vendor::derive::describe_aad; +use stack_kms_vendor::{ + classify_status, KeyBytes, KeyVendorBackend, VendorRequest, VendorResponse, VendorTransport, +}; +use vitaminc::protected::Protected; +use zeroize::Zeroizing; + +use crate::config::AwsKmsConfig; +use crate::credentials::{CredentialsError, CredentialsProvider}; +use crate::sigv4::{sign, SigningParams}; +use crate::time::{system_clock, Clock}; + +/// This client's vendor: what [`KeyVendorBackend::vendor`] answers and what its errors name. +const VENDOR: KeyVendor = KeyVendor::Aws; +/// The AWS KMS JSON protocol content type. +const CONTENT_TYPE: &str = "application/x-amz-json-1.1"; +/// The SigV4 signing name of AWS KMS. +const SERVICE: &str = "kms"; + +/// The encryption context key that binds the whole associated data. +pub const CONTEXT_AAD: &str = "cipherstash:aad"; +/// The encryption context key of the keyset id. +pub const CONTEXT_KEYSET_ID: &str = "cipherstash:keyset-id"; +/// The encryption context key of what the key is. It is `root-key`, the +/// key in a keyset record. +pub const CONTEXT_PURPOSE: &str = "cipherstash:purpose"; + +/// A failure of an AWS 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 AwsKmsError { + /// AWS KMS answered with an error. + #[error("AWS KMS {operation} failed with HTTP {status}: {code}: {message}")] + Service { + /// The operation (`Decrypt`). + operation: &'static str, + /// The HTTP status. + status: u16, + /// The AWS error code (`ThrottlingException`), or `unknown`. + code: String, + /// The AWS error message. + message: String, + }, + /// The request got no response. + #[error("AWS KMS {operation} got no response: {source}")] + Transport { + /// The operation. + operation: &'static str, + /// The transport failure. + source: stack_kms_vendor::TransportError, + }, + /// The credentials could not be got. + #[error(transparent)] + Credentials(#[from] CredentialsError), + /// The request could not be signed. + #[error(transparent)] + Signing(#[from] crate::sigv4::SigningError), + /// AWS KMS answered with a success that this client cannot read. + #[error("AWS KMS {operation} returned a response this client cannot read: {reason}")] + InvalidResponse { + /// The operation. + operation: &'static str, + /// What is wrong with it. + reason: &'static str, + }, +} + +fn backend(kind: BackendErrorKind, error: AwsKmsError) -> Error { + Error::Backend { + backend: VENDOR.into(), + kind, + source: Box::new(error), + } +} + +/// The failure kind of an AWS KMS error code. `None` for a code with no +/// fixed kind: the HTTP status decides. +pub fn error_kind(code: &str) -> Option { + Some(match code { + "ThrottlingException" => BackendErrorKind::Throttled, + "AccessDeniedException" + | "UnrecognizedClientException" + | "InvalidSignatureException" + | "IncompleteSignature" + | "ExpiredTokenException" + | "MissingAuthenticationToken" + | "InvalidClientTokenId" => BackendErrorKind::AccessDenied, + "NotFoundException" => BackendErrorKind::NotFound, + "InvalidCiphertextException" | "IncorrectKeyException" | "InvalidKeyUsageException" => { + BackendErrorKind::InvalidCiphertext + } + "KMSInternalException" + | "DependencyTimeoutException" + | "KeyUnavailableException" + | "DisabledException" + | "KMSInvalidStateException" + | "ServiceUnavailable" + | "InternalFailure" => BackendErrorKind::Unavailable, + _ => return None, + }) +} + +#[derive(Deserialize)] +struct ErrorBody { + #[serde(rename = "__type")] + kind: Option, + #[serde(alias = "Message")] + message: Option, +} + +/// The error of a failed AWS KMS response. +fn service_error(operation: &'static str, response: &VendorResponse) -> Error { + let body: Option = serde_json::from_slice(&response.body).ok(); + // `__type` may carry a namespace (`com.amazonaws.kms#NotFoundException`); + // the `x-amzn-ErrorType` header may carry a suffix after a colon. + let code = body + .as_ref() + .and_then(|body| body.kind.as_deref()) + .or_else(|| response.header_value("x-amzn-errortype")) + .map(|code| { + let code = code.rsplit('#').next().unwrap_or(code); + code.split(':').next().unwrap_or(code).to_owned() + }) + .unwrap_or_else(|| "unknown".to_owned()); + let message = body.and_then(|body| body.message).unwrap_or_default(); + let kind = error_kind(&code) + .or_else(|| classify_status(response.status)) + .unwrap_or(BackendErrorKind::Unavailable); + // A 4xx with an unknown code is a request AWS KMS refused, not a + // damaged ciphertext, unless the code says so. + let kind = match (kind, error_kind(&code)) { + (BackendErrorKind::InvalidCiphertext, None) => BackendErrorKind::AccessDenied, + (kind, _) => kind, + }; + backend( + kind, + AwsKmsError::Service { + operation, + status: response.status, + code, + message, + }, + ) +} + +/// The encryption context that binds `aad`. +/// +/// The context always holds the whole associated data. When `aad` is the +/// associated data of a keyset record key, the context also holds the +/// purpose and the keyset id in a readable form. CloudTrail shows these +/// fields, and a key policy can use them in `kms:EncryptionContext:` +/// conditions. +pub fn encryption_context(aad: &[u8]) -> BTreeMap<&'static str, String> { + let mut context = BTreeMap::new(); + let _ = context.insert(CONTEXT_AAD, Base64UrlUnpadded::encode_string(aad)); + if let Some(description) = describe_aad(aad) { + let _ = context.insert(CONTEXT_PURPOSE, description.purpose.to_owned()); + let _ = context.insert(CONTEXT_KEYSET_ID, description.keyset_id.to_string()); + } + context +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct EncryptRequest<'a> { + key_id: &'a str, + plaintext: &'a str, + encryption_context: &'a BTreeMap<&'static str, String>, +} + +#[derive(Serialize)] +#[serde(rename_all = "PascalCase")] +struct DecryptRequest<'a> { + key_id: &'a str, + ciphertext_blob: &'a str, + encryption_context: &'a BTreeMap<&'static str, String>, +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct KeyResponse { + ciphertext_blob: Option, + plaintext: Option, +} + +/// An AWS KMS client, and the AWS KMS key vendor backend +/// ([`KeyVendor::Aws`]). +/// +/// It speaks the AWS KMS JSON API (`TrentService.Encrypt` and `Decrypt`). +/// It signs each request with SigV4 in-crate. It sends each request through +/// a [`VendorTransport`], so the same code runs on native Rust and in the +/// WASI guest. +/// +/// * `key_ref` (from the keyset record) is the AWS KMS key: a key ARN, a +/// key id, or an alias ARN. AWS KMS keys rotate behind one ARN, so the +/// ARN is the versioned reference. Prefer the key ARN, because an alias +/// can be repointed. +/// * The associated data is bound as the encryption context +/// ([`encryption_context`]). AWS KMS refuses a `Decrypt` whose context is +/// not the one the key was encrypted under. +/// * The derived data key mode only. One `Encrypt` call creates or rewraps +/// a keyset record. One `Decrypt` call unwraps the root key of a keyset, +/// once for each root key TTL. No call is made for each value. +/// * Errors map to [`BackendErrorKind`] by the AWS error code +/// ([`error_kind`]), then by HTTP status. +pub struct AwsKms { + config: AwsKmsConfig, + transport: T, + credentials: C, + clock: Clock, +} + +impl std::fmt::Debug for AwsKms { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AwsKms") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +impl AwsKms { + /// A client for `config`, sending through `transport`, signing with the + /// credentials of `credentials`. + pub fn new(config: AwsKmsConfig, transport: T, credentials: C) -> Self { + Self { + config, + transport, + credentials, + clock: system_clock(), + } + } + + /// Read the signing time from `clock`. For tests with recorded + /// requests. + pub fn with_clock(mut self, clock: Clock) -> Self { + self.clock = clock; + self + } + + /// The configuration. + pub fn config(&self) -> &AwsKmsConfig { + &self.config + } + + /// Sign and send one AWS KMS JSON request. + /// + /// `operation` is the `TrentService` operation (`Decrypt`). The body + /// is wiped after it is sent. Any AWS KMS operation can be sent this + /// way (for example `CreateKey` in a test). [`KeyVendorBackend`] uses + /// `Encrypt` and `Decrypt` only. + pub async fn send_json( + &self, + operation: &'static str, + body: Zeroizing>, + ) -> Result { + let credentials = self.credentials.credentials().await.map_err(|error| { + let kind = error.kind(); + backend(kind, AwsKmsError::Credentials(error)) + })?; + let mut request = VendorRequest { + method: "POST".to_owned(), + url: self.config.endpoint().to_owned(), + headers: vec![ + ("content-type".to_owned(), CONTENT_TYPE.to_owned()), + ( + "x-amz-target".to_owned(), + format!("TrentService.{operation}"), + ), + ], + body, + }; + sign( + &mut request, + &SigningParams { + credentials: &credentials, + region: self.config.region(), + service: SERVICE, + time: (self.clock)(), + }, + ) + .map_err(|error| backend(BackendErrorKind::Unavailable, error.into()))?; + let response = self.transport.send(request).await.map_err(|source| { + backend( + BackendErrorKind::Unavailable, + AwsKmsError::Transport { operation, source }, + ) + })?; + if classify_status(response.status).is_some() { + return Err(service_error(operation, &response)); + } + serde_json::from_slice(&response.body).map_err(|_| invalid(operation, "invalid JSON")) + } + + async fn call( + &self, + operation: &'static str, + request: &Q, + ) -> Result { + let body = Zeroizing::new( + serde_json::to_vec(request) + .map_err(|_| invalid(operation, "unserializable request"))?, + ); + self.send_json(operation, body).await + } +} + +fn invalid(operation: &'static str, reason: &'static str) -> Error { + backend( + BackendErrorKind::Unavailable, + AwsKmsError::InvalidResponse { operation, reason }, + ) +} + +/// The ciphertext blob of a response, decoded. +fn ciphertext(operation: &'static str, response: &KeyResponse) -> Result, Error> { + let blob = response + .ciphertext_blob + .as_deref() + .ok_or_else(|| invalid(operation, "no CiphertextBlob"))?; + Base64::decode_vec(blob).map_err(|_| invalid(operation, "CiphertextBlob is not base64")) +} + +/// The 256-bit plaintext key of a response, decoded. Every copy is wiped. +fn plaintext_key(operation: &'static str, response: KeyResponse) -> Result { + let encoded = Zeroizing::new( + response + .plaintext + .ok_or_else(|| invalid(operation, "no Plaintext"))?, + ); + let decoded = Zeroizing::new( + Base64::decode_vec(&encoded).map_err(|_| invalid(operation, "Plaintext is not base64"))?, + ); + let mut key = Zeroizing::new([0u8; 32]); + if decoded.len() != key.len() { + // A key of another size is not a key this crate wrapped. + return Err(backend( + BackendErrorKind::InvalidCiphertext, + AwsKmsError::InvalidResponse { + operation, + reason: "the plaintext is not a 256-bit key", + }, + )); + } + key.copy_from_slice(&decoded); + Ok(Protected::new(*key)) +} + +impl KeyVendorBackend for AwsKms { + fn vendor(&self) -> KeyVendor { + VENDOR + } + + async fn wrap(&self, key_ref: &str, key: &KeyBytes, aad: &[u8]) -> Result, Error> { + use vitaminc::protected::Controlled; + let context = encryption_context(aad); + let plaintext = Zeroizing::new(Base64::encode_string(key.risky_ref())); + let response = self + .call( + "Encrypt", + &EncryptRequest { + key_id: key_ref, + plaintext: &plaintext, + encryption_context: &context, + }, + ) + .await?; + ciphertext("Encrypt", &response) + } + + async fn unwrap(&self, key_ref: &str, wrapped: &[u8], aad: &[u8]) -> Result { + let context = encryption_context(aad); + let blob = Base64::encode_string(wrapped); + let response = self + .call( + "Decrypt", + &DecryptRequest { + key_id: key_ref, + ciphertext_blob: &blob, + encryption_context: &context, + }, + ) + .await?; + plaintext_key("Decrypt", response) + } + + async fn sleep(&self, duration: Duration) { + self.transport.sleep(duration).await; + } +} + +#[cfg(test)] +mod tests { + use stack_kms::KeyMode; + use stack_kms_vendor::derive::record_key_aad; + use uuid::Uuid; + + use super::*; + + #[test] + fn the_context_binds_the_aad_and_names_its_fields() { + let id = Uuid::from_u128(1); + let aad = record_key_aad(id, KeyVendor::Aws, KeyMode::Derived); + let context = encryption_context(&aad); + assert_eq!( + context.get(CONTEXT_AAD).unwrap(), + &Base64UrlUnpadded::encode_string(&aad) + ); + assert_eq!(context.get(CONTEXT_PURPOSE).unwrap(), "root-key"); + assert_eq!(context.get(CONTEXT_KEYSET_ID).unwrap(), &id.to_string()); + assert_eq!(context.len(), 3); + + let context = encryption_context(b"other"); + assert_eq!(context.len(), 1); + } + + #[test] + fn error_codes_map_to_kinds() { + for (code, kind) in [ + ("ThrottlingException", BackendErrorKind::Throttled), + ("AccessDeniedException", BackendErrorKind::AccessDenied), + ("NotFoundException", BackendErrorKind::NotFound), + ( + "InvalidCiphertextException", + BackendErrorKind::InvalidCiphertext, + ), + ("IncorrectKeyException", BackendErrorKind::InvalidCiphertext), + ("KMSInternalException", BackendErrorKind::Unavailable), + ("DependencyTimeoutException", BackendErrorKind::Unavailable), + ("DisabledException", BackendErrorKind::Unavailable), + ] { + assert_eq!(error_kind(code), Some(kind), "{code}"); + } + assert_eq!(error_kind("ValidationException"), None); + } + + fn kind_of(response: VendorResponse) -> BackendErrorKind { + match service_error("Decrypt", &response) { + Error::Backend { kind, .. } => kind, + other => panic!("{other:?}"), + } + } + + #[test] + fn the_code_comes_from_the_body_or_the_header() { + let namespaced = VendorResponse::new( + 400, + br#"{"__type":"com.amazonaws.kms#NotFoundException","message":"m"}"#.to_vec(), + ); + assert_eq!(kind_of(namespaced), BackendErrorKind::NotFound); + let header = VendorResponse::new(400, vec![]).header( + "x-amzn-ErrorType", + "ThrottlingException:http://internal.amazon.com/coral/", + ); + assert_eq!(kind_of(header), BackendErrorKind::Throttled); + // No code: the status decides, and an unknown 4xx is a refused + // request. + assert_eq!( + kind_of(VendorResponse::new(503, b"busy".to_vec())), + BackendErrorKind::Unavailable + ); + assert_eq!( + kind_of(VendorResponse::new( + 400, + br#"{"__type":"ValidationException"}"#.to_vec() + )), + BackendErrorKind::AccessDenied + ); + assert_eq!( + kind_of(VendorResponse::new(403, vec![])), + BackendErrorKind::AccessDenied + ); + } + + #[test] + fn the_error_message_names_the_operation_and_code_only() { + let error = service_error( + "Decrypt", + &VendorResponse::new( + 400, + br#"{"__type":"InvalidCiphertextException","Message":"bad"}"#.to_vec(), + ), + ); + let Error::Backend { source, .. } = error else { + panic!() + }; + assert_eq!( + source.to_string(), + "AWS KMS Decrypt failed with HTTP 400: InvalidCiphertextException: bad" + ); + } +} diff --git a/packages/stack-kms-aws/src/config.rs b/packages/stack-kms-aws/src/config.rs new file mode 100644 index 000000000..39653488a --- /dev/null +++ b/packages/stack-kms-aws/src/config.rs @@ -0,0 +1,167 @@ +//! The region and endpoint of an AWS KMS client. + +use crate::credentials::{process_env, EnvLookup}; + +/// The configuration is not usable. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ConfigError { + /// No region was given, and neither `AWS_REGION` nor + /// `AWS_DEFAULT_REGION` is set. + #[error("no AWS region: set AWS_REGION or give one in the config")] + NoRegion, + /// The region is not a region name. + #[error("{0:?} is not an AWS region name")] + InvalidRegion(String), + /// The endpoint is not an absolute HTTP or HTTPS URL. + #[error("{0:?} is not an HTTP or HTTPS endpoint URL")] + InvalidEndpoint(String), +} + +/// The DNS suffix of the AWS partition of `region`. +pub(crate) fn dns_suffix(region: &str) -> &'static str { + if region.starts_with("cn-") { + "amazonaws.com.cn" + } else { + "amazonaws.com" + } +} + +/// The region and endpoint of an [`AwsKms`](crate::AwsKms) client. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AwsKmsConfig { + region: String, + endpoint: String, +} + +impl AwsKmsConfig { + /// The AWS KMS endpoint of `region` (`https://kms..amazonaws.com/`). + pub fn new(region: impl Into) -> Result { + let region = region.into(); + let valid = !region.is_empty() + && region.len() <= 64 + && region + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + if !valid { + return Err(ConfigError::InvalidRegion(region)); + } + let endpoint = format!("https://kms.{region}.{}/", dns_suffix(®ion)); + Ok(Self { region, endpoint }) + } + + /// The region from `AWS_REGION`, else `AWS_DEFAULT_REGION`, and the + /// endpoint from `AWS_ENDPOINT_URL_KMS`, else `AWS_ENDPOINT_URL`, else + /// the region's. + pub fn from_env() -> Result { + Self::from_env_lookup(&process_env()) + } + + /// As [`from_env`](Self::from_env), reading the variables with `env`. + pub fn from_env_lookup(env: &EnvLookup) -> Result { + let region = env("AWS_REGION") + .or_else(|| env("AWS_DEFAULT_REGION")) + .ok_or(ConfigError::NoRegion)?; + let config = Self::new(region)?; + match env("AWS_ENDPOINT_URL_KMS").or_else(|| env("AWS_ENDPOINT_URL")) { + Some(endpoint) => config.with_endpoint(endpoint), + None => Ok(config), + } + } + + /// Send to `endpoint` instead of the region's AWS KMS endpoint: a VPC + /// endpoint, a FIPS endpoint, or a local emulator such as LocalStack + /// (`http://localhost:4566`). Requests are still signed for the region. + 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() { + return Err(ConfigError::InvalidEndpoint(endpoint)); + } + self.endpoint = parsed.to_string(); + Ok(self) + } + + /// The region. + pub fn region(&self) -> &str { + &self.region + } + + /// The endpoint URL. + pub fn endpoint(&self) -> &str { + &self.endpoint + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use super::*; + + fn env(pairs: &[(&str, &str)]) -> EnvLookup { + let map: HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(); + Arc::new(move |name| map.get(name).cloned()) + } + + #[test] + fn the_endpoint_follows_the_region_and_partition() { + let config = AwsKmsConfig::new("ap-southeast-2").unwrap(); + assert_eq!(config.region(), "ap-southeast-2"); + assert_eq!( + config.endpoint(), + "https://kms.ap-southeast-2.amazonaws.com/" + ); + assert_eq!( + AwsKmsConfig::new("cn-north-1").unwrap().endpoint(), + "https://kms.cn-north-1.amazonaws.com.cn/" + ); + for bad in ["", "US-EAST-1", "us east 1", "us-east-1.evil.com/"] { + assert_eq!( + AwsKmsConfig::new(bad), + Err(ConfigError::InvalidRegion(bad.to_owned())) + ); + } + } + + #[test] + fn the_region_and_endpoint_come_from_the_environment() { + assert_eq!( + AwsKmsConfig::from_env_lookup(&env(&[])), + Err(ConfigError::NoRegion) + ); + let config = + AwsKmsConfig::from_env_lookup(&env(&[("AWS_DEFAULT_REGION", "eu-west-1")])).unwrap(); + assert_eq!(config.region(), "eu-west-1"); + let config = AwsKmsConfig::from_env_lookup(&env(&[ + ("AWS_REGION", "us-west-2"), + ("AWS_DEFAULT_REGION", "eu-west-1"), + ("AWS_ENDPOINT_URL", "http://localhost:4566"), + ])) + .unwrap(); + assert_eq!(config.region(), "us-west-2"); + assert_eq!(config.endpoint(), "http://localhost:4566/"); + let config = AwsKmsConfig::from_env_lookup(&env(&[ + ("AWS_REGION", "us-west-2"), + ("AWS_ENDPOINT_URL", "http://localhost:4566"), + ("AWS_ENDPOINT_URL_KMS", "http://localhost:4567"), + ])) + .unwrap(); + assert_eq!(config.endpoint(), "http://localhost:4567/"); + } + + #[test] + fn an_endpoint_must_be_http_or_https() { + let config = AwsKmsConfig::new("us-east-1").unwrap(); + for bad in ["localhost:4566", "ftp://localhost/", "not a url"] { + assert_eq!( + config.clone().with_endpoint(bad), + Err(ConfigError::InvalidEndpoint(bad.to_owned())) + ); + } + } +} diff --git a/packages/stack-kms-aws/src/credentials/chain.rs b/packages/stack-kms-aws/src/credentials/chain.rs new file mode 100644 index 000000000..825869015 --- /dev/null +++ b/packages/stack-kms-aws/src/credentials/chain.rs @@ -0,0 +1,90 @@ +//! The default credential chain. + +use stack_kms_vendor::VendorTransport; + +use super::{ + CachedCredentials, Credentials, CredentialsError, CredentialsProvider, EcsCredentials, + EnvLookup, ImdsCredentials, WebIdentityCredentials, +}; + +/// The credentials source the environment names, chosen once, in the order +/// the AWS SDKs use: +/// +/// 1. static credentials: `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`; +/// 2. IRSA: `AWS_WEB_IDENTITY_TOKEN_FILE` and `AWS_ROLE_ARN`; +/// 3. the container endpoint: `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` or +/// `AWS_CONTAINER_CREDENTIALS_FULL_URI`; +/// 4. IMDSv2, unless `AWS_EC2_METADATA_DISABLED=true`. +/// +/// The shared config and credentials files (`~/.aws/config`) and SSO are +/// not read. Native only. +#[derive(Debug)] +pub enum DefaultCredentialsSource { + /// Static credentials from the environment. + Static(Credentials), + /// IRSA. + WebIdentity(WebIdentityCredentials), + /// The container endpoint. + Ecs(EcsCredentials), + /// IMDSv2. + Imds(ImdsCredentials), +} + +/// The default credential chain, cached. +pub type DefaultCredentials = CachedCredentials>; + +impl DefaultCredentialsSource { + /// Choose the source from the variables `env` reads. `region` is the + /// STS region for IRSA. Fails when no source is configured (IMDS + /// disabled and nothing else set) or a container URI is refused. + pub fn from_env_lookup( + env: &EnvLookup, + transport: T, + region: Option<&str>, + ) -> Result { + if let Some(credentials) = Credentials::from_env_lookup(env) { + return Ok(Self::Static(credentials)); + } + if env("AWS_WEB_IDENTITY_TOKEN_FILE").is_some() && env("AWS_ROLE_ARN").is_some() { + return WebIdentityCredentials::from_env_lookup(env, transport, region) + .map(Self::WebIdentity) + .ok_or_else(|| CredentialsError::access_denied("IRSA is not configured")); + } + if env("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI").is_some() + || env("AWS_CONTAINER_CREDENTIALS_FULL_URI").is_some() + { + return EcsCredentials::from_env_lookup(env, transport)? + .map(Self::Ecs) + .ok_or_else(|| { + CredentialsError::access_denied("the container endpoint is not configured") + }); + } + ImdsCredentials::from_env_lookup(env, transport) + .map(Self::Imds) + .ok_or_else(|| { + CredentialsError::access_denied( + "no AWS credentials are configured: set AWS_ACCESS_KEY_ID and \ + AWS_SECRET_ACCESS_KEY, IRSA, a container endpoint, or enable IMDS", + ) + }) + } + + /// Choose the source from the process environment, and cache it. + pub fn cached_from_env( + transport: T, + region: Option<&str>, + ) -> Result, CredentialsError> { + Self::from_env_lookup(&super::process_env(), transport, region).map(CachedCredentials::new) + } +} + +impl CredentialsProvider for DefaultCredentialsSource { + async fn credentials(&self) -> Result { + match self { + Self::Static(credentials) => credentials.credentials().await, + Self::WebIdentity(provider) => provider.credentials().await, + Self::Ecs(provider) => provider.credentials().await, + Self::Imds(provider) => provider.credentials().await, + } + } +} diff --git a/packages/stack-kms-aws/src/credentials/ecs.rs b/packages/stack-kms-aws/src/credentials/ecs.rs new file mode 100644 index 000000000..c1d9bdfc7 --- /dev/null +++ b/packages/stack-kms-aws/src/credentials/ecs.rs @@ -0,0 +1,166 @@ +//! Credentials from the ECS container endpoint (and EKS Pod Identity). + +use stack_kms_vendor::{VendorRequest, VendorTransport}; +use zeroize::Zeroizing; + +use super::response::json_credentials; +use super::{Credentials, CredentialsError, CredentialsProvider, EnvLookup}; + +/// The ECS task credentials host, for `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`. +const ECS_HOST: &str = "http://169.254.170.2"; + +/// Where the authorization token of the container endpoint comes from. +/// +/// No `Debug`: the `Debug` of `Zeroizing` prints the token. +#[derive(Clone)] +enum AuthToken { + None, + /// `AWS_CONTAINER_AUTHORIZATION_TOKEN`. + Value(Zeroizing), + /// `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE`, read on each fetch (EKS + /// Pod Identity rotates it). + File(std::path::PathBuf), +} + +/// Credentials from the container credentials endpoint: ECS task roles +/// (`AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`) and EKS Pod Identity or any +/// other `AWS_CONTAINER_CREDENTIALS_FULL_URI`. +/// +/// Wrap it in [`CachedCredentials`](super::CachedCredentials). Native only. +pub struct EcsCredentials { + transport: T, + uri: String, + token: AuthToken, +} + +impl std::fmt::Debug for EcsCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EcsCredentials") + .field("uri", &self.uri) + .finish_non_exhaustive() + } +} + +impl EcsCredentials { + /// From `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`, else + /// `AWS_CONTAINER_CREDENTIALS_FULL_URI`, with the token from + /// `AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE` or + /// `AWS_CONTAINER_AUTHORIZATION_TOKEN`. `Ok(None)` when neither URI is + /// set. + /// + /// A full URI must be HTTPS, or plain HTTP to a loopback address or to + /// one of the ECS and EKS link-local hosts, as the AWS SDKs require: + /// the credentials must not cross a network in the clear. + pub fn from_env_lookup( + env: &EnvLookup, + transport: T, + ) -> Result, CredentialsError> { + let uri = if let Some(relative) = env("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") { + format!("{ECS_HOST}{relative}") + } else if let Some(full) = env("AWS_CONTAINER_CREDENTIALS_FULL_URI") { + check_full_uri(&full)?; + full + } else { + return Ok(None); + }; + let token = if let Some(file) = env("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE") { + AuthToken::File(file.into()) + } else if let Some(value) = env("AWS_CONTAINER_AUTHORIZATION_TOKEN") { + AuthToken::Value(Zeroizing::new(value)) + } else { + AuthToken::None + }; + Ok(Some(Self { + transport, + uri, + token, + })) + } +} + +/// Refuse a full URI that would send credentials in the clear over a +/// network. +fn check_full_uri(uri: &str) -> Result<(), CredentialsError> { + let parsed = url::Url::parse(uri).map_err(|_| { + CredentialsError::access_denied("AWS_CONTAINER_CREDENTIALS_FULL_URI is not a valid URL") + })?; + let allowed = match parsed.scheme() { + "https" => true, + "http" => match parsed.host() { + Some(url::Host::Ipv4(ip)) => { + ip.is_loopback() + || ip.octets() == [169, 254, 170, 2] + || ip.octets() == [169, 254, 170, 23] + } + Some(url::Host::Ipv6(ip)) => { + ip.is_loopback() || ip.segments() == [0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x23] + } + Some(url::Host::Domain(domain)) => domain == "localhost", + None => false, + }, + _ => false, + }; + if allowed { + Ok(()) + } else { + Err(CredentialsError::access_denied( + "AWS_CONTAINER_CREDENTIALS_FULL_URI must be HTTPS, or HTTP to a loopback or ECS/EKS link-local host", + )) + } +} + +impl CredentialsProvider for EcsCredentials { + async fn credentials(&self) -> Result { + let mut request = VendorRequest::new("GET", self.uri.as_str()); + let token = match &self.token { + AuthToken::None => None, + AuthToken::Value(value) => Some(value.clone()), + AuthToken::File(path) => Some(Zeroizing::new( + std::fs::read_to_string(path) + .map_err(|error| { + CredentialsError::access_denied(format!( + "the container authorization token file {} could not be read: {error}", + path.display() + )) + })? + .trim() + .to_owned(), + )), + }; + if let Some(token) = token { + request = request.header("authorization", token.as_str()); + } + let response = self.transport.send(request).await.map_err(|error| { + CredentialsError::unavailable(format!("the container endpoint did not answer: {error}")) + })?; + json_credentials("the container endpoint", &response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_uris_must_not_cross_a_network_in_the_clear() { + for allowed in [ + "https://example.com/creds", + "http://127.0.0.1:8080/creds", + "http://localhost/creds", + "http://169.254.170.2/v2/credentials", + "http://169.254.170.23/v1/credentials", + "http://[fd00:ec2::23]/v1/credentials", + "http://[::1]/creds", + ] { + assert!(check_full_uri(allowed).is_ok(), "{allowed}"); + } + for refused in [ + "http://example.com/creds", + "http://10.0.0.1/creds", + "ftp://127.0.0.1/creds", + "not a url", + ] { + assert!(check_full_uri(refused).is_err(), "{refused}"); + } + } +} diff --git a/packages/stack-kms-aws/src/credentials/imds.rs b/packages/stack-kms-aws/src/credentials/imds.rs new file mode 100644 index 000000000..745d821ab --- /dev/null +++ b/packages/stack-kms-aws/src/credentials/imds.rs @@ -0,0 +1,109 @@ +//! Credentials from the EC2 instance metadata service, IMDSv2. + +use stack_kms_vendor::{classify_status, VendorRequest, VendorTransport}; +use zeroize::Zeroizing; + +use super::response::{json_credentials, status_failure}; +use super::{Credentials, CredentialsError, CredentialsProvider, EnvLookup}; + +/// The IMDS endpoint when `AWS_EC2_METADATA_SERVICE_ENDPOINT` is not set. +pub const DEFAULT_IMDS_ENDPOINT: &str = "http://169.254.169.254"; +/// The lifetime asked for a session token, in seconds (six hours, the +/// maximum). +const TOKEN_TTL_SECONDS: &str = "21600"; +const CREDENTIALS_PATH: &str = "/latest/meta-data/iam/security-credentials/"; + +/// Credentials of the EC2 instance profile, from the instance metadata +/// service with IMDSv2 session tokens. IMDSv1 is not used. +/// +/// Each fetch gets a session token (`PUT /latest/api/token`), the role +/// name, and the role's credentials. Wrap it in +/// [`CachedCredentials`](super::CachedCredentials). Off EC2 the endpoint +/// does not answer, so give the transport a short connect timeout. Native +/// only. +pub struct ImdsCredentials { + transport: T, + endpoint: String, +} + +impl std::fmt::Debug for ImdsCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImdsCredentials") + .field("endpoint", &self.endpoint) + .finish_non_exhaustive() + } +} + +impl ImdsCredentials { + /// IMDS at `endpoint` (`http://169.254.169.254`, no trailing slash + /// needed). + pub fn new(transport: T, endpoint: impl Into) -> Self { + Self { + transport, + endpoint: endpoint.into().trim_end_matches('/').to_owned(), + } + } + + /// IMDS at `AWS_EC2_METADATA_SERVICE_ENDPOINT`, else the default + /// endpoint. `None` when `AWS_EC2_METADATA_DISABLED` is `true`. + pub fn from_env_lookup(env: &EnvLookup, transport: T) -> Option { + if env("AWS_EC2_METADATA_DISABLED").is_some_and(|value| value.eq_ignore_ascii_case("true")) + { + return None; + } + let endpoint = env("AWS_EC2_METADATA_SERVICE_ENDPOINT") + .unwrap_or_else(|| DEFAULT_IMDS_ENDPOINT.to_owned()); + Some(Self::new(transport, endpoint)) + } + + async fn send(&self, request: VendorRequest) -> Result>, CredentialsError> { + let response = self.transport.send(request).await.map_err(|error| { + CredentialsError::unavailable(format!("IMDS did not answer: {error}")) + })?; + if classify_status(response.status).is_some() { + return Err(status_failure("IMDS", &response)); + } + Ok(response.body) + } +} + +impl CredentialsProvider for ImdsCredentials { + async fn credentials(&self) -> Result { + let token = self + .send( + VendorRequest::new("PUT", format!("{}/latest/api/token", self.endpoint)) + .header("x-aws-ec2-metadata-token-ttl-seconds", TOKEN_TTL_SECONDS), + ) + .await?; + let token = Zeroizing::new( + String::from_utf8(token.to_vec()) + .map_err(|_| CredentialsError::unavailable("IMDS returned an invalid token"))?, + ); + let roles = self + .send( + VendorRequest::new("GET", format!("{}{CREDENTIALS_PATH}", self.endpoint)) + .header("x-aws-ec2-metadata-token", token.as_str()), + ) + .await?; + let role = std::str::from_utf8(&roles) + .ok() + .and_then(|roles| roles.lines().map(str::trim).find(|line| !line.is_empty())) + .map(str::to_owned) + .ok_or_else(|| { + CredentialsError::access_denied("the instance has no IAM instance profile") + })?; + let request = VendorRequest::new( + "GET", + format!( + "{}{CREDENTIALS_PATH}{}", + self.endpoint, + crate::sigv4::uri_encode(role.as_bytes()) + ), + ) + .header("x-aws-ec2-metadata-token", token.as_str()); + let response = self.transport.send(request).await.map_err(|error| { + CredentialsError::unavailable(format!("IMDS did not answer: {error}")) + })?; + json_credentials("IMDS", &response) + } +} diff --git a/packages/stack-kms-aws/src/credentials/mod.rs b/packages/stack-kms-aws/src/credentials/mod.rs new file mode 100644 index 000000000..3eb131d37 --- /dev/null +++ b/packages/stack-kms-aws/src/credentials/mod.rs @@ -0,0 +1,270 @@ +//! AWS credentials and where they come from. +//! +//! [`CredentialsProvider`] is the seam: the client asks it for +//! [`Credentials`] before it signs each request. This crate provides: +//! +//! * [`Credentials`] itself: static credentials, from explicit values or +//! from `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and +//! `AWS_SESSION_TOKEN` ([`Credentials::from_env`]). Everywhere. +//! * [`CachedCredentials`]: keeps the credentials another provider returns +//! and fetches new ones before they expire. Everywhere. +//! * Workload identity, **native only**: [`WebIdentityCredentials`] (IRSA +//! on EKS: STS `AssumeRoleWithWebIdentity`), [`EcsCredentials`] (the ECS +//! and EKS Pod Identity container endpoint) and [`ImdsCredentials`] (the +//! EC2 instance metadata service, IMDSv2). [`DefaultCredentials`] picks +//! one from the environment, as the AWS SDKs do. +//! +//! In the WASI guest the host supplies the credentials (ADR-0014): the +//! guest implements [`CredentialsProvider`] over a host import. + +use std::fmt; +use std::future::Future; + +use stack_kms::MaybeSend; +use stack_kms_vendor::{CredentialError, CredentialKind, Expiring, Expiry, FetchCredential}; +use web_time::SystemTime; +use zeroize::Zeroizing; + +#[cfg(not(target_arch = "wasm32"))] +mod chain; +#[cfg(not(target_arch = "wasm32"))] +mod ecs; +#[cfg(not(target_arch = "wasm32"))] +mod imds; +#[cfg(not(target_arch = "wasm32"))] +mod response; +#[cfg(not(target_arch = "wasm32"))] +mod web_identity; + +#[cfg(not(target_arch = "wasm32"))] +pub use chain::{DefaultCredentials, DefaultCredentialsSource}; +#[cfg(not(target_arch = "wasm32"))] +pub use ecs::EcsCredentials; +#[cfg(not(target_arch = "wasm32"))] +pub use imds::{ImdsCredentials, DEFAULT_IMDS_ENDPOINT}; +#[cfg(not(target_arch = "wasm32"))] +pub use web_identity::WebIdentityCredentials; + +/// 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())) +} + +/// AWS credentials: an access key id, a secret access key and, for +/// temporary credentials, a session token and an expiry time. +/// +/// The secret and the session token are wiped on drop and never printed. +#[derive(Clone)] +pub struct Credentials { + access_key_id: String, + secret_access_key: Zeroizing, + session_token: Option>, + expires_at: Option, +} + +impl fmt::Debug for Credentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Credentials") + .field("access_key_id", &self.access_key_id) + .field("secret_access_key", &"") + .field( + "session_token", + &self.session_token.as_ref().map(|_| ""), + ) + .field("expires_at", &self.expires_at) + .finish() + } +} + +impl Credentials { + /// Credentials from explicit values. They do not expire. + pub fn new( + access_key_id: impl Into, + secret_access_key: impl Into, + session_token: Option, + ) -> Self { + Self { + access_key_id: access_key_id.into(), + secret_access_key: Zeroizing::new(secret_access_key.into()), + session_token: session_token.map(Zeroizing::new), + expires_at: None, + } + } + + /// The same credentials, expiring at `expires_at`. + pub fn with_expiry(mut self, expires_at: SystemTime) -> Self { + self.expires_at = Some(expires_at); + self + } + + /// Credentials from `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and + /// (optional) `AWS_SESSION_TOKEN`. `None` unless both of the first two + /// are set. + pub fn from_env() -> Option { + Self::from_env_lookup(&process_env()) + } + + /// As [`from_env`](Self::from_env), reading the variables with `env`. + pub fn from_env_lookup(env: &EnvLookup) -> Option { + let access_key_id = env("AWS_ACCESS_KEY_ID")?; + let secret = Zeroizing::new(env("AWS_SECRET_ACCESS_KEY")?); + Some(Self { + access_key_id, + secret_access_key: secret, + session_token: env("AWS_SESSION_TOKEN").map(Zeroizing::new), + expires_at: None, + }) + } + + /// The access key id. + pub fn access_key_id(&self) -> &str { + &self.access_key_id + } + + /// The secret access key. + pub fn secret_access_key(&self) -> &str { + &self.secret_access_key + } + + /// The session token, for temporary credentials. + pub fn session_token(&self) -> Option<&str> { + self.session_token.as_ref().map(|token| token.as_str()) + } + + /// When the credentials expire. `None` for credentials that do not. + pub fn expires_at(&self) -> Option { + self.expires_at + } +} + +impl Expiring for Credentials { + fn expiry(&self) -> Expiry { + self.expires_at.map_or(Expiry::Never, Expiry::At) + } +} + +/// The credential kind of this crate: [`Credentials`], one set for the +/// client. It names [`CredentialsError`] and [`CachedCredentials`]. +#[derive(Debug)] +pub enum AwsCredentials {} + +impl CredentialKind for AwsCredentials { + const LABEL: &'static str = "AWS credentials"; + type Key = (); + type Credential = Credentials; +} + +/// The credentials could not be got. The message starts with "AWS +/// credentials". +pub type CredentialsError = CredentialError; + +/// Where a client gets its credentials. The client asks before it signs +/// each request, so a provider that fetches should cache +/// ([`CachedCredentials`]). +/// +/// The returned future is bounded by [`MaybeSend`]: `Send` on native +/// targets, unbounded on wasm32. +pub trait CredentialsProvider: Send + Sync { + /// The current credentials. + fn credentials( + &self, + ) -> impl Future> + MaybeSend; +} + +impl CredentialsProvider for Credentials { + fn credentials( + &self, + ) -> impl Future> + MaybeSend { + std::future::ready(Ok(self.clone())) + } +} + +pub use stack_kms_vendor::DEFAULT_REFRESH_BEFORE_EXPIRY; + +/// Keeps the credentials `inner` returns, and fetches new ones when they +/// are near their expiry: [`DEFAULT_REFRESH_BEFORE_EXPIRY`] before, or a +/// third of their life before when they live 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 credentials +/// have not yet expired, they are still returned. Credentials with no +/// expiry are kept for good. +pub type CachedCredentials

= stack_kms_vendor::CachedToken; + +impl FetchCredential

for AwsCredentials { + fn fetch<'a>( + provider: &'a P, + _key: &'a (), + ) -> impl Future> + MaybeSend + 'a { + provider.credentials() + } +} + +impl CredentialsProvider for CachedCredentials

{ + fn credentials( + &self, + ) -> impl Future> + MaybeSend { + self.get(&()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::Arc; + + use super::*; + + fn env(pairs: &[(&str, &str)]) -> EnvLookup { + let map: HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(); + Arc::new(move |name| map.get(name).cloned()) + } + + #[test] + fn static_credentials_come_from_the_environment() { + assert!(Credentials::from_env_lookup(&env(&[])).is_none()); + assert!(Credentials::from_env_lookup(&env(&[("AWS_ACCESS_KEY_ID", "AKID")])).is_none()); + let credentials = Credentials::from_env_lookup(&env(&[ + ("AWS_ACCESS_KEY_ID", "AKID"), + ("AWS_SECRET_ACCESS_KEY", "secret"), + ])) + .unwrap(); + assert_eq!(credentials.access_key_id(), "AKID"); + assert_eq!(credentials.secret_access_key(), "secret"); + assert_eq!(credentials.session_token(), None); + assert_eq!(credentials.expires_at(), None); + let credentials = Credentials::from_env_lookup(&env(&[ + ("AWS_ACCESS_KEY_ID", "AKID"), + ("AWS_SECRET_ACCESS_KEY", "secret"), + ("AWS_SESSION_TOKEN", "token"), + ])) + .unwrap(); + assert_eq!(credentials.session_token(), Some("token")); + } + + #[test] + fn debug_hides_the_secret_and_the_token() { + let text = format!( + "{:?}", + Credentials::new("AKID", "the-secret", Some("the-token".into())) + ); + assert!(text.contains("AKID")); + assert!(!text.contains("the-secret")); + assert!(!text.contains("the-token")); + } + + #[test] + fn the_expiry_is_the_expiry_time() { + let credentials = Credentials::new("AKID", "secret", None); + assert_eq!(credentials.expiry(), Expiry::Never); + let at = web_time::UNIX_EPOCH + std::time::Duration::from_secs(60); + assert_eq!(credentials.with_expiry(at).expiry(), Expiry::At(at)); + } +} diff --git a/packages/stack-kms-aws/src/credentials/response.rs b/packages/stack-kms-aws/src/credentials/response.rs new file mode 100644 index 000000000..7c0282058 --- /dev/null +++ b/packages/stack-kms-aws/src/credentials/response.rs @@ -0,0 +1,207 @@ +//! Parsing the credential responses of STS, the container endpoint and +//! IMDS. + +use serde::Deserialize; +use stack_kms::BackendErrorKind; +use stack_kms_vendor::{classify_status, VendorResponse}; +use zeroize::Zeroizing; + +use super::{Credentials, CredentialsError}; +use crate::time::parse_rfc3339; + +/// The JSON credentials of the container endpoint and IMDS. +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct JsonCredentials { + code: Option, + access_key_id: String, + secret_access_key: String, + token: Option, + expiration: Option, +} + +/// The failure for a response that is not a success, from `source`. +pub(super) fn status_failure(source: &str, response: &VendorResponse) -> CredentialsError { + let kind = classify_status(response.status).unwrap_or(BackendErrorKind::Unavailable); + // A 400 from a credential source is a refused request, not a damaged + // ciphertext. + let kind = match kind { + BackendErrorKind::InvalidCiphertext => BackendErrorKind::AccessDenied, + kind => kind, + }; + CredentialsError::new(kind, format!("{source} returned HTTP {}", response.status)) +} + +/// Parse the JSON credentials the container endpoint and IMDS return. +pub(super) fn json_credentials( + source: &str, + response: &VendorResponse, +) -> Result { + if classify_status(response.status).is_some() { + return Err(status_failure(source, response)); + } + let parsed: JsonCredentials = serde_json::from_slice(&response.body) + .map_err(|_| CredentialsError::unavailable(format!("{source} returned invalid JSON")))?; + // Move the secrets into wiping containers at once. + let secret = Zeroizing::new(parsed.secret_access_key); + let token = parsed.token.map(Zeroizing::new); + if let Some(code) = parsed.code.as_deref() { + if code != "Success" { + return Err(CredentialsError::access_denied(format!( + "{source} returned the code {code:?}" + ))); + } + } + let mut credentials = Credentials { + access_key_id: parsed.access_key_id, + secret_access_key: secret, + session_token: token, + expires_at: None, + }; + if let Some(expiration) = parsed.expiration.as_deref() { + credentials.expires_at = Some(parse_rfc3339(expiration).ok_or_else(|| { + CredentialsError::unavailable(format!("{source} returned an invalid expiry time")) + })?); + } + Ok(credentials) +} + +/// The text of the first `…` element in `xml`, with the five +/// XML entities decoded. +pub(super) fn xml_text(xml: &str, tag: &str) -> Option> { + let open = format!("<{tag}>"); + let close = format!(""); + let start = xml.find(&open)? + open.len(); + let end = start + xml[start..].find(&close)?; + let raw = &xml[start..end]; + Some(Zeroizing::new( + raw.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&"), + )) +} + +/// Parse an STS `AssumeRole*` response: the credentials, or the STS error. +pub(super) fn sts_credentials(response: &VendorResponse) -> Result { + let xml = std::str::from_utf8(&response.body) + .map_err(|_| CredentialsError::unavailable("STS returned a body that is not UTF-8"))?; + if classify_status(response.status).is_some() { + let code = xml_text(xml, "Code"); + let code = code.as_deref().map(String::as_str).unwrap_or(""); + let kind = match code { + "Throttling" | "ThrottlingException" | "RequestLimitExceeded" => { + BackendErrorKind::Throttled + } + "IDPCommunicationError" | "ServiceUnavailable" | "InternalFailure" => { + BackendErrorKind::Unavailable + } + _ => return Err(status_failure(&format!("STS ({code})"), response)), + }; + return Err(CredentialsError::new( + kind, + format!("STS returned HTTP {} ({code})", response.status), + )); + } + let field = |tag: &str| { + xml_text(xml, tag) + .ok_or_else(|| CredentialsError::unavailable(format!("the STS response has no {tag}"))) + }; + let access_key_id = field("AccessKeyId")?; + let expiration = field("Expiration")?; + Ok(Credentials { + access_key_id: access_key_id.to_string(), + secret_access_key: field("SecretAccessKey")?, + session_token: Some(field("SessionToken")?), + expires_at: Some( + parse_rfc3339(&expiration).ok_or_else(|| { + CredentialsError::unavailable("STS returned an invalid expiry time") + })?, + ), + }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use web_time::UNIX_EPOCH; + + use super::*; + + #[test] + fn json_credentials_parse() { + let response = VendorResponse::new( + 200, + br#"{"Code":"Success","Type":"AWS-HMAC","AccessKeyId":"ASIA1","SecretAccessKey":"s","Token":"t","Expiration":"2015-08-30T12:36:00Z"}"#.to_vec(), + ); + let credentials = json_credentials("IMDS", &response).unwrap(); + assert_eq!(credentials.access_key_id(), "ASIA1"); + assert_eq!(credentials.secret_access_key(), "s"); + assert_eq!(credentials.session_token(), Some("t")); + assert_eq!( + credentials.expires_at(), + Some(UNIX_EPOCH + Duration::from_secs(1_440_938_160)) + ); + } + + #[test] + fn json_credentials_refuse_failures() { + let failed = br#"{"Code":"Failed","AccessKeyId":"","SecretAccessKey":""}"#.to_vec(); + let error = json_credentials("IMDS", &VendorResponse::new(200, failed)).unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + let error = + json_credentials("IMDS", &VendorResponse::new(200, b"not json".to_vec())).unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::Unavailable); + let bad_time = + br#"{"AccessKeyId":"a","SecretAccessKey":"s","Expiration":"tomorrow"}"#.to_vec(); + let error = json_credentials("IMDS", &VendorResponse::new(200, bad_time)).unwrap_err(); + assert!(error.to_string().contains("expiry")); + for (status, kind) in [ + (404, BackendErrorKind::NotFound), + (401, BackendErrorKind::AccessDenied), + (400, BackendErrorKind::AccessDenied), + (500, BackendErrorKind::Unavailable), + (429, BackendErrorKind::Throttled), + ] { + let error = json_credentials("ECS", &VendorResponse::new(status, vec![])).unwrap_err(); + assert_eq!(error.kind(), kind, "{status}"); + } + } + + #[test] + fn xml_text_decodes_entities() { + assert_eq!( + xml_text("x&y<>"'", "B") + .unwrap() + .as_str(), + "x&y<>\"'" + ); + assert!(xml_text("x", "B").is_none()); + assert!(xml_text("", "B").is_none()); + } + + #[test] + fn sts_errors_map_to_kinds() { + for (status, code, kind) in [ + (400, "Throttling", BackendErrorKind::Throttled), + (400, "IDPCommunicationError", BackendErrorKind::Unavailable), + (403, "AccessDenied", BackendErrorKind::AccessDenied), + (400, "InvalidIdentityToken", BackendErrorKind::AccessDenied), + (400, "ExpiredTokenException", BackendErrorKind::AccessDenied), + (503, "Other", BackendErrorKind::Unavailable), + ] { + let body = format!( + "Sender{code}m" + ); + let error = + sts_credentials(&VendorResponse::new(status, body.into_bytes())).unwrap_err(); + assert_eq!(error.kind(), kind, "{code}"); + } + let error = sts_credentials(&VendorResponse::new(200, b"".to_vec())).unwrap_err(); + assert!(error.to_string().contains("AccessKeyId")); + let error = sts_credentials(&VendorResponse::new(200, vec![0xff])).unwrap_err(); + assert!(error.to_string().contains("UTF-8")); + } +} diff --git a/packages/stack-kms-aws/src/credentials/web_identity.rs b/packages/stack-kms-aws/src/credentials/web_identity.rs new file mode 100644 index 000000000..b4051e28d --- /dev/null +++ b/packages/stack-kms-aws/src/credentials/web_identity.rs @@ -0,0 +1,122 @@ +//! IRSA: credentials from STS `AssumeRoleWithWebIdentity`. + +use std::path::PathBuf; + +use stack_kms_vendor::{VendorRequest, VendorTransport}; + +use super::response::sts_credentials; +use super::{Credentials, CredentialsError, CredentialsProvider, EnvLookup}; +use crate::sigv4::uri_encode; + +/// The STS API version. +const STS_VERSION: &str = "2011-06-15"; +/// The session name when `AWS_ROLE_SESSION_NAME` is not set. +const DEFAULT_SESSION_NAME: &str = "stack-kms-aws"; + +/// Credentials for an IAM role, assumed with a web identity token: IAM +/// Roles for Service Accounts (IRSA) on EKS, or any OIDC token file. +/// +/// Each fetch reads the token file again (the platform rotates it) and +/// calls STS `AssumeRoleWithWebIdentity`, which needs no AWS credentials. +/// Wrap it in [`CachedCredentials`](super::CachedCredentials). Native only. +pub struct WebIdentityCredentials { + transport: T, + role_arn: String, + token_file: PathBuf, + session_name: String, + sts_endpoint: String, +} + +impl std::fmt::Debug for WebIdentityCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WebIdentityCredentials") + .field("role_arn", &self.role_arn) + .field("token_file", &self.token_file) + .field("session_name", &self.session_name) + .field("sts_endpoint", &self.sts_endpoint) + .finish_non_exhaustive() + } +} + +impl WebIdentityCredentials { + /// Assume `role_arn` with the token in `token_file`, through the STS + /// endpoint of `region` (the global endpoint for `None`). + pub fn new( + transport: T, + role_arn: impl Into, + token_file: impl Into, + region: Option<&str>, + ) -> Self { + Self { + transport, + role_arn: role_arn.into(), + token_file: token_file.into(), + session_name: DEFAULT_SESSION_NAME.to_owned(), + sts_endpoint: sts_endpoint(region), + } + } + + /// From `AWS_WEB_IDENTITY_TOKEN_FILE`, `AWS_ROLE_ARN` and (optional) + /// `AWS_ROLE_SESSION_NAME`. `None` unless the first two are set. + pub fn from_env_lookup(env: &EnvLookup, transport: T, region: Option<&str>) -> Option { + let token_file = env("AWS_WEB_IDENTITY_TOKEN_FILE")?; + let role_arn = env("AWS_ROLE_ARN")?; + let mut provider = Self::new(transport, role_arn, token_file, region); + if let Some(name) = env("AWS_ROLE_SESSION_NAME") { + provider.session_name = name; + } + Some(provider) + } + + /// Use `endpoint` for STS (a full URL). + pub fn with_sts_endpoint(mut self, endpoint: impl Into) -> Self { + self.sts_endpoint = endpoint.into(); + self + } + + /// Use `name` as the role session name. + pub fn with_session_name(mut self, name: impl Into) -> Self { + self.session_name = name.into(); + self + } +} + +/// The STS endpoint of `region`. +pub(super) fn sts_endpoint(region: Option<&str>) -> String { + match region { + Some(region) => format!( + "https://sts.{region}.{}/", + crate::config::dns_suffix(region) + ), + None => "https://sts.amazonaws.com/".to_owned(), + } +} + +impl CredentialsProvider for WebIdentityCredentials { + async fn credentials(&self) -> Result { + let token = zeroize::Zeroizing::new(std::fs::read_to_string(&self.token_file).map_err( + |error| { + CredentialsError::access_denied(format!( + "the web identity token file {} could not be read: {error}", + self.token_file.display() + )) + }, + )?); + let body = zeroize::Zeroizing::new(format!( + "Action=AssumeRoleWithWebIdentity&RoleArn={}&RoleSessionName={}&Version={STS_VERSION}&WebIdentityToken={}", + uri_encode(self.role_arn.as_bytes()), + uri_encode(self.session_name.as_bytes()), + uri_encode(token.trim().as_bytes()), + )); + let request = VendorRequest::new("POST", self.sts_endpoint.as_str()) + .header( + "content-type", + "application/x-www-form-urlencoded; charset=utf-8", + ) + .body(body.as_bytes().to_vec()); + let response = self.transport.send(request).await.map_err(|error| { + CredentialsError::unavailable(format!("STS did not answer: {error}")) + })?; + sts_credentials(&response) + } +} diff --git a/packages/stack-kms-aws/src/lib.rs b/packages/stack-kms-aws/src/lib.rs new file mode 100644 index 000000000..f716909c7 --- /dev/null +++ b/packages/stack-kms-aws/src/lib.rs @@ -0,0 +1,141 @@ +#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +//! The AWS KMS key vendor backend for Stack Encrypt (ADR-0013, ADR-0014). +//! +//! [`AwsKms`] is a small, hand-written AWS KMS client. It has the JSON API +//! (`Encrypt` and `Decrypt`), SigV4 signing in-crate ([`sigv4`]), and a +//! [`VendorTransport`](stack_kms_vendor::VendorTransport) for the HTTP. It +//! implements [`KeyVendorBackend`](stack_kms_vendor::KeyVendorBackend), so +//! [`VendorKeySource`](stack_kms_vendor::VendorKeySource) builds the +//! derived data key mode on it. +//! +//! The client calls AWS KMS for keyset records only. One `Encrypt` call +//! creates or rewraps a record. One `Decrypt` call unwraps the root key of +//! a keyset, once for each root key TTL. The data keys and index keys are +//! derived from the root key locally, so no call is made for each value. +//! +//! No AWS SDK crate is used. The AWS SDK crates need tokio and hyper, and +//! this crate also builds for wasm32-wasip1, the Go module's WASI guest. +//! +//! # Native Rust +//! +//! ```no_run +//! # async fn example() -> Result<(), Box> { +//! use stack_kms_aws::AwsKms; +//! use stack_kms_vendor::{KeysetRecord, VendorKeySource}; +//! use stack_kms::KeyMode; +//! +//! // AWS_REGION, and credentials from the default chain: environment +//! // variables, IRSA, the container endpoint or IMDSv2. +//! let kms = AwsKms::from_env()?; +//! let record = KeysetRecord::create( +//! &kms, +//! "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", +//! 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. +//! +//! # The WASI guest +//! +//! The guest builds [`AwsKms::new`] with its host transport and a +//! [`CredentialsProvider`] over a host import. The workload identity +//! providers (IRSA, the container endpoint, IMDS) are native only: in the +//! guest, the host's own credential chain supplies the credentials. + +#![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))] + +mod client; +mod config; +pub mod credentials; +pub mod sigv4; +mod time; + +pub use client::{ + encryption_context, error_kind, AwsKms, AwsKmsError, CONTEXT_AAD, CONTEXT_KEYSET_ID, + CONTEXT_PURPOSE, +}; +pub use config::{AwsKmsConfig, ConfigError}; +pub use credentials::{ + AwsCredentials, CachedCredentials, Credentials, CredentialsError, CredentialsProvider, + DEFAULT_REFRESH_BEFORE_EXPIRY, +}; +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::credentials::{DefaultCredentials, DefaultCredentialsSource}; + use crate::{AwsKms, AwsKmsConfig}; + + /// [`AwsKms::from_env`] failed. + #[derive(Debug, thiserror::Error)] + pub enum FromEnvError { + /// The region or endpoint. + #[error(transparent)] + Config(#[from] crate::ConfigError), + /// The credentials. + #[error(transparent)] + Credentials(#[from] crate::CredentialsError), + /// The HTTP client. + #[error(transparent)] + Transport(#[from] stack_kms_vendor::TransportError), + } + + /// The client [`AwsKms::from_env`] builds. + pub type DefaultAwsKms = AwsKms>; + + impl DefaultAwsKms { + /// A client configured from the environment, as the AWS SDKs are: + /// the region and endpoint from [`AwsKmsConfig::from_env`], and the + /// credentials from the default chain + /// ([`DefaultCredentialsSource`]), cached. Native only. + /// + /// The credentials transport opens connections with a 1 second + /// limit, so that off EC2 the IMDS probe fails fast. + pub fn from_env() -> Result { + let config = AwsKmsConfig::from_env()?; + let transport = HttpVendorTransport::new()?; + let credentials_transport = + HttpVendorTransport::with_options(HttpVendorTransportOptions { + timeout: Duration::from_secs(10), + connect_timeout: Some(Duration::from_secs(1)), + })?; + let credentials = DefaultCredentialsSource::cached_from_env( + credentials_transport, + Some(config.region()), + )?; + Ok(AwsKms::new(config, transport, credentials)) + } + } +} + +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +pub use native::{DefaultAwsKms, FromEnvError}; diff --git a/packages/stack-kms-aws/src/sigv4.rs b/packages/stack-kms-aws/src/sigv4.rs new file mode 100644 index 000000000..a07972a37 --- /dev/null +++ b/packages/stack-kms-aws/src/sigv4.rs @@ -0,0 +1,440 @@ +//! AWS Signature Version 4, in-crate. +//! +//! The AWS SDKs and the `aws-sigv4` crate are not used: the SDKs need tokio +//! and hyper, and this crate must build for wasm32-wasip1 (ADR-0014). +//! Signing is a few HMAC-SHA256 calls, so it is written here and tested +//! against the AWS SigV4 test suite. +//! +//! [`sign`] adds `host` (when the request has none), `x-amz-date`, +//! `x-amz-security-token` (for temporary credentials) and `authorization` +//! to a [`VendorRequest`]. It signs every header the request carries. +//! +//! See . + +use std::fmt::Write as _; + +use hmac::{Hmac, KeyInit, Mac}; +use sha2::{Digest, Sha256}; +use stack_kms_vendor::VendorRequest; +use web_time::SystemTime; +use zeroize::Zeroizing; + +use crate::credentials::Credentials; +use crate::time::amz_date; + +/// The signing algorithm name. +pub const ALGORITHM: &str = "AWS4-HMAC-SHA256"; + +/// A request could not be signed. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum SigningError { + /// The request URL does not parse or has no host. + #[error("the request URL is not a valid absolute URL with a host")] + InvalidUrl, +} + +/// What a signature is made with, apart from the request. +#[derive(Clone, Copy, Debug)] +pub struct SigningParams<'a> { + /// The credentials. + pub credentials: &'a Credentials, + /// The AWS region (`us-east-1`). + pub region: &'a str, + /// The service signing name (`kms`, `sts`). + pub service: &'a str, + /// The signing time. + pub time: SystemTime, +} + +/// Sign `request` in place (see the module docs). +pub fn sign(request: &mut VendorRequest, params: &SigningParams<'_>) -> Result<(), SigningError> { + let url = url::Url::parse(&request.url).map_err(|_| SigningError::InvalidUrl)?; + let host = url.host_str().ok_or(SigningError::InvalidUrl)?; + let host = match url.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_owned(), + }; + let (date, timestamp) = amz_date(params.time); + if request.header_value("host").is_none() { + request.headers.push(("host".to_owned(), host)); + } + request + .headers + .push(("x-amz-date".to_owned(), timestamp.clone())); + if let Some(token) = params.credentials.session_token() { + request + .headers + .push(("x-amz-security-token".to_owned(), token.to_owned())); + } + + let (canonical, signed_headers) = canonical_request(request, &url); + let scope = format!("{date}/{}/{}/aws4_request", params.region, params.service); + let string_to_sign = format!( + "{ALGORITHM}\n{timestamp}\n{scope}\n{}", + hex(&Sha256::digest(canonical.as_bytes())) + ); + let key = signing_key( + params.credentials.secret_access_key(), + &date, + params.region, + params.service, + ); + let signature = hex(&hmac(key.as_slice(), string_to_sign.as_bytes())); + request.headers.push(( + "authorization".to_owned(), + format!( + "{ALGORITHM} Credential={}/{scope}, SignedHeaders={signed_headers}, Signature={signature}", + params.credentials.access_key_id() + ), + )); + Ok(()) +} + +/// The canonical request of `request` and its signed header list. +fn canonical_request(request: &VendorRequest, url: &url::Url) -> (String, String) { + let mut headers: Vec<(String, String)> = Vec::new(); + for (name, value) in &request.headers { + let name = name.to_ascii_lowercase(); + let value = value.split_whitespace().collect::>().join(" "); + match headers.iter_mut().find(|(n, _)| *n == name) { + Some((_, joined)) => { + joined.push(','); + joined.push_str(&value); + } + None => headers.push((name, value)), + } + } + headers.sort_by(|a, b| a.0.cmp(&b.0)); + let signed_headers = headers + .iter() + .map(|(name, _)| name.as_str()) + .collect::>() + .join(";"); + let mut canonical = String::new(); + canonical.push_str(&request.method); + canonical.push('\n'); + canonical.push_str(&canonical_uri(url.path())); + canonical.push('\n'); + canonical.push_str(&canonical_query(url.query().unwrap_or(""))); + canonical.push('\n'); + for (name, value) in &headers { + let _ = writeln!(canonical, "{name}:{value}"); + } + canonical.push('\n'); + canonical.push_str(&signed_headers); + canonical.push('\n'); + canonical.push_str(&hex(&Sha256::digest(request.body.as_slice()))); + (canonical, signed_headers) +} + +/// Each path segment decoded and encoded again with the SigV4 rules. +fn canonical_uri(path: &str) -> String { + if path.is_empty() { + return "/".to_owned(); + } + path.split('/') + .map(|segment| uri_encode(&percent_decode(segment))) + .collect::>() + .join("/") +} + +/// The query pairs decoded, encoded again with the SigV4 rules, and sorted +/// by name, then value. +fn canonical_query(query: &str) -> String { + let mut pairs: Vec<(String, String)> = query + .split('&') + .filter(|pair| !pair.is_empty()) + .map(|pair| { + let (name, value) = pair.split_once('=').unwrap_or((pair, "")); + ( + uri_encode(&percent_decode(name)), + uri_encode(&percent_decode(value)), + ) + }) + .collect(); + pairs.sort(); + pairs + .iter() + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join("&") +} + +/// Percent-encode every byte except the RFC 3986 unreserved characters. +pub(crate) fn uri_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len()); + for &byte in bytes { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + out.push(byte as char); + } else { + let _ = write!(out, "%{byte:02X}"); + } + } + out +} + +/// Decode `%XX` escapes. An escape that is not two hex digits is kept as +/// it is. +fn percent_decode(text: &str) -> Vec { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let decoded = (bytes[i] == b'%') + .then(|| bytes.get(i + 1..i + 3)) + .flatten() + .and_then(|hex| std::str::from_utf8(hex).ok()) + .and_then(|hex| u8::from_str_radix(hex, 16).ok()); + match decoded { + Some(byte) => { + out.push(byte); + i += 3; + } + None => { + out.push(bytes[i]); + i += 1; + } + } + } + out +} + +/// The SigV4 signing key: `HMAC` chained over the date, region, service and +/// `aws4_request`, from `AWS4` + the secret. +fn signing_key(secret: &str, date: &str, region: &str, service: &str) -> Zeroizing> { + let initial = Zeroizing::new(format!("AWS4{secret}")); + let k_date = Zeroizing::new(hmac(initial.as_bytes(), date.as_bytes())); + let k_region = Zeroizing::new(hmac(&k_date, region.as_bytes())); + let k_service = Zeroizing::new(hmac(&k_region, service.as_bytes())); + Zeroizing::new(hmac(&k_service, b"aws4_request")) +} + +fn hmac(key: &[u8], data: &[u8]) -> Vec { + // HMAC takes a key of any length. + #[allow(clippy::expect_used)] + let mut mac = Hmac::::new_from_slice(key).expect("HMAC accepts any key length"); + mac.update(data); + mac.finalize().into_bytes().to_vec() +} + +pub(crate) fn hex(bytes: &[u8]) -> String { + bytes.iter().fold(String::new(), |mut out, byte| { + let _ = write!(out, "{byte:02x}"); + out + }) +} + +#[cfg(test)] +mod tests { + //! Vectors from the AWS SigV4 test suite (`aws-sig-v4-test-suite`, as + //! shipped in botocore and the AWS SDKs). Every case uses the access key + //! `AKIDEXAMPLE`, the secret `wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY`, + //! region `us-east-1`, service `service` and the time + //! `20150830T123600Z`, except the IAM example from the AWS SigV4 + //! documentation (service `iam`). + + use std::time::Duration; + + use web_time::UNIX_EPOCH; + + use super::*; + + const ACCESS_KEY: &str = "AKIDEXAMPLE"; + const SECRET: &str = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"; + + fn suite_time() -> SystemTime { + UNIX_EPOCH + Duration::from_secs(1_440_938_160) + } + + fn signed(mut request: VendorRequest, service: &str) -> VendorRequest { + let credentials = Credentials::new(ACCESS_KEY, SECRET, None); + sign( + &mut request, + &SigningParams { + credentials: &credentials, + region: "us-east-1", + service, + time: suite_time(), + }, + ) + .unwrap(); + request + } + + fn canonical(request: &VendorRequest) -> String { + canonical_request(request, &url::Url::parse(&request.url).unwrap()).0 + } + + fn authorization(request: &VendorRequest) -> &str { + request.header_value("authorization").unwrap() + } + + #[test] + fn get_vanilla() { + let request = signed( + VendorRequest::new("GET", "https://example.amazonaws.com/"), + "service", + ); + assert_eq!( + canonical(&request.clone().without_authorization()), + "GET\n/\n\nhost:example.amazonaws.com\nx-amz-date:20150830T123600Z\n\nhost;x-amz-date\n\ + e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + authorization(&request), + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \ + SignedHeaders=host;x-amz-date, \ + Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31" + ); + } + + #[test] + fn post_vanilla() { + let request = signed( + VendorRequest::new("POST", "https://example.amazonaws.com/"), + "service", + ); + assert_eq!( + authorization(&request), + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \ + SignedHeaders=host;x-amz-date, \ + Signature=5da7c1a2acd57cee7505fc6676e4e544621c30862966e37dddb68e92efbe5d6b" + ); + } + + #[test] + fn get_vanilla_query_order_key_case() { + let request = signed( + VendorRequest::new( + "GET", + "https://example.amazonaws.com/?Param2=value2&Param1=value1", + ), + "service", + ); + assert_eq!( + authorization(&request), + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \ + SignedHeaders=host;x-amz-date, \ + Signature=b97d918cfa904a5beff61c982a1b6f458b799221646efd99d3219ec94cdf2500" + ); + } + + #[test] + fn post_x_www_form_urlencoded() { + let request = signed( + VendorRequest::new("POST", "https://example.amazonaws.com/") + .header("Content-Type", "application/x-www-form-urlencoded") + .body(b"Param1=value1".to_vec()), + "service", + ); + assert_eq!( + authorization(&request), + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request, \ + SignedHeaders=content-type;host;x-amz-date, \ + Signature=ff11897932ad3f4e8b18135d722051e5ac45fc38421b1da7b9d196a0fe09473a" + ); + } + + /// The worked example in the AWS SigV4 documentation: IAM `ListUsers`. + #[test] + fn the_documented_iam_example() { + let request = signed( + VendorRequest::new( + "GET", + "https://iam.amazonaws.com/?Action=ListUsers&Version=2010-05-08", + ) + .header( + "Content-Type", + "application/x-www-form-urlencoded; charset=utf-8", + ), + "iam", + ); + let canonical = canonical(&request.clone().without_authorization()); + assert_eq!( + hex(&Sha256::digest(canonical.as_bytes())), + "f536975d06c0309214f805bb90ccff089219ecd68b2577efef23edd43b7e1a59" + ); + assert_eq!( + authorization(&request), + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request, \ + SignedHeaders=content-type;host;x-amz-date, \ + Signature=5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7" + ); + } + + #[test] + fn a_session_token_is_sent_and_signed() { + let credentials = Credentials::new(ACCESS_KEY, SECRET, Some("session-token".to_owned())); + let mut request = VendorRequest::new("POST", "https://kms.us-east-1.amazonaws.com/"); + sign( + &mut request, + &SigningParams { + credentials: &credentials, + region: "us-east-1", + service: "kms", + time: suite_time(), + }, + ) + .unwrap(); + assert_eq!( + request.header_value("x-amz-security-token"), + Some("session-token") + ); + assert!( + authorization(&request).contains("SignedHeaders=host;x-amz-date;x-amz-security-token,") + ); + } + + #[test] + fn a_port_is_part_of_the_host() { + let request = signed(VendorRequest::new("POST", "http://localhost:4566/"), "kms"); + assert_eq!(request.header_value("host"), Some("localhost:4566")); + let request = signed( + VendorRequest::new("POST", "https://kms.us-east-1.amazonaws.com:443/"), + "kms", + ); + assert_eq!( + request.header_value("host"), + Some("kms.us-east-1.amazonaws.com") + ); + } + + #[test] + fn an_invalid_url_is_refused() { + let credentials = Credentials::new(ACCESS_KEY, SECRET, None); + let params = SigningParams { + credentials: &credentials, + region: "us-east-1", + service: "kms", + time: suite_time(), + }; + for url in ["not a url", "data:text/plain,hello"] { + assert_eq!( + sign(&mut VendorRequest::new("GET", url), ¶ms), + Err(SigningError::InvalidUrl) + ); + } + } + + #[test] + fn paths_and_queries_canonicalise() { + assert_eq!(canonical_uri(""), "/"); + assert_eq!(canonical_uri("/example%20space/"), "/example%20space/"); + assert_eq!(canonical_uri("/a~b/%7e"), "/a~b/~"); + assert_eq!(canonical_query("b=2&a=1&a=0"), "a=0&a=1&b=2"); + assert_eq!(canonical_query("Param=a%20b&x"), "Param=a%20b&x="); + assert_eq!(canonical_query("k=%zz"), "k=%25zz"); + assert_eq!(canonical_query(""), ""); + } + + trait WithoutAuthorization { + fn without_authorization(self) -> Self; + } + + impl WithoutAuthorization for VendorRequest { + fn without_authorization(mut self) -> Self { + self.headers.retain(|(name, _)| name != "authorization"); + self + } + } +} diff --git a/packages/stack-kms-aws/src/time.rs b/packages/stack-kms-aws/src/time.rs new file mode 100644 index 000000000..71ba29a5a --- /dev/null +++ b/packages/stack-kms-aws/src/time.rs @@ -0,0 +1,159 @@ +//! UTC dates without a date crate: the SigV4 timestamp and the expiry +//! times in AWS credential responses. + +use std::time::Duration; + +use web_time::{SystemTime, UNIX_EPOCH}; + +/// The time source of a client: `SystemTime::now` unless a test sets +/// another. +pub use stack_kms_vendor::{system_clock, Clock}; + +/// The civil date of a day count since 1970-01-01 (Howard Hinnant's +/// `civil_from_days`). +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let year = yoe + era * 400 + i64::from(month <= 2); + (year, month, day) +} + +/// The day count since 1970-01-01 of a civil date (Howard Hinnant's +/// `days_from_civil`). +// Only the native credential providers parse expiry times. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +fn days_from_civil(year: i64, month: u32, day: u32) -> i64 { + let year = if month <= 2 { year - 1 } else { year }; + let era = year.div_euclid(400); + let yoe = year.rem_euclid(400); + let month = i64::from(month); + let mp = if month > 2 { month - 3 } else { month + 9 }; + let doy = (153 * mp + 2) / 5 + i64::from(day) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146_097 + doe - 719_468 +} + +/// The SigV4 date (`YYYYMMDD`) and timestamp (`YYYYMMDD'T'HHMMSS'Z'`) of +/// `time`, in UTC. A time before 1970 counts as 1970-01-01. +pub(crate) fn amz_date(time: SystemTime) -> (String, String) { + let seconds = time + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs()); + let days = i64::try_from(seconds / 86_400).unwrap_or(i64::MAX / 2); + let rest = seconds % 86_400; + let (year, month, day) = civil_from_days(days); + let date = format!("{year:04}{month:02}{day:02}"); + let timestamp = format!( + "{date}T{:02}{:02}{:02}Z", + rest / 3600, + (rest / 60) % 60, + rest % 60 + ); + (date, timestamp) +} + +/// Parse an RFC 3339 UTC time as AWS returns it: `2026-11-05T12:00:00Z`, +/// with optional fractional seconds, and `Z` or `+00:00`. `None` for any +/// other form. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) fn parse_rfc3339(text: &str) -> Option { + let text = text.trim(); + let text = text + .strip_suffix('Z') + .or_else(|| text.strip_suffix("+00:00"))?; + let (date, time) = text.split_once('T')?; + let mut date_parts = date.split('-'); + let year: i64 = date_parts.next()?.parse().ok()?; + let month: u32 = date_parts.next()?.parse().ok()?; + let day: u32 = date_parts.next()?.parse().ok()?; + if date_parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + let (time, fraction) = match time.split_once('.') { + Some((time, fraction)) => (time, Some(fraction)), + None => (time, None), + }; + let mut time_parts = time.split(':'); + let hour: u64 = time_parts.next()?.parse().ok()?; + let minute: u64 = time_parts.next()?.parse().ok()?; + let second: u64 = time_parts.next()?.parse().ok()?; + if time_parts.next().is_some() || hour > 23 || minute > 59 || second > 60 { + return None; + } + let nanos = match fraction { + Some(digits) if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) => { + let digits = &digits[..digits.len().min(9)]; + let value: u32 = digits.parse().ok()?; + value * 10u32.pow(9 - digits.len() as u32) + } + Some(_) => return None, + None => 0, + }; + let days = u64::try_from(days_from_civil(year, month, day)).ok()?; + let seconds = days * 86_400 + hour * 3600 + minute * 60 + second; + UNIX_EPOCH.checked_add(Duration::new(seconds, nanos)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(seconds: u64) -> SystemTime { + UNIX_EPOCH + Duration::from_secs(seconds) + } + + #[test] + fn amz_date_formats_utc() { + // 2015-08-30T12:36:00Z, the time of the AWS SigV4 test suite. + let (date, timestamp) = amz_date(at(1_440_938_160)); + assert_eq!(date, "20150830"); + assert_eq!(timestamp, "20150830T123600Z"); + assert_eq!(amz_date(at(0)).1, "19700101T000000Z"); + // A leap day, and the last second of a year. + assert_eq!(amz_date(at(951_782_400)).1, "20000229T000000Z"); + assert_eq!(amz_date(at(1_798_761_599)).1, "20261231T235959Z"); + } + + #[test] + fn rfc3339_parses_the_forms_aws_returns() { + assert_eq!( + parse_rfc3339("2015-08-30T12:36:00Z"), + Some(at(1_440_938_160)) + ); + assert_eq!( + parse_rfc3339("2015-08-30T12:36:00+00:00"), + Some(at(1_440_938_160)) + ); + assert_eq!( + parse_rfc3339("2015-08-30T12:36:00.250Z"), + Some(at(1_440_938_160) + Duration::from_millis(250)) + ); + assert_eq!(parse_rfc3339("2000-02-29T00:00:00Z"), Some(at(951_782_400))); + for bad in [ + "2015-08-30T12:36:00", + "2015-08-30 12:36:00Z", + "2015-13-30T12:36:00Z", + "2015-08-30T24:00:00Z", + "2015-08-30T12:36:00.Z", + "2015-08-30T12:36:00.1xZ", + "1969-12-31T23:59:59Z", + "", + ] { + assert_eq!(parse_rfc3339(bad), None, "{bad}"); + } + } + + #[test] + fn the_two_conversions_agree() { + for days in [0, 1, 59, 60, 365, 10_957, 11_016, 20_000, 2_932_896] { + let (year, month, day) = civil_from_days(days); + assert_eq!(days_from_civil(year, month, day), days); + } + } +} diff --git a/packages/stack-kms-aws/tasks.toml b/packages/stack-kms-aws/tasks.toml new file mode 100644 index 000000000..c8b26e30e --- /dev/null +++ b/packages/stack-kms-aws/tasks.toml @@ -0,0 +1,25 @@ +# Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc +# warning fails the build. The root `doc` task fans out over every +# `doc:`; the root `test:doc` runs the doc examples. +["doc:stack-kms-aws"] +description = "Build docs for stack-kms-aws with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-kms-aws --no-deps --all-features" + +# The AWS KMS backend against a running LocalStack (tests/localstack.rs). The +# test skips itself when STACK_KMS_AWS_LOCALSTACK_ENDPOINT is unset, which is +# right for `cargo nextest run --workspace` and wrong here: a skip would read +# as a pass. So this task refuses to run without it. tests-crates.yml's +# `localstack` job runs LocalStack as a service and calls this task. +# +# docker run -d --rm -p 4566:4566 -e SERVICES=kms localstack/localstack:4.4 +# STACK_KMS_AWS_LOCALSTACK_ENDPOINT=http://localhost:4566 mise run test:localstack:stack-kms-aws +["test:localstack:stack-kms-aws"] +description = "Run the stack-kms-aws tests against LocalStack (needs STACK_KMS_AWS_LOCALSTACK_ENDPOINT)" +run = """ +if [ -z "${STACK_KMS_AWS_LOCALSTACK_ENDPOINT:-}" ]; then + echo "error: set STACK_KMS_AWS_LOCALSTACK_ENDPOINT (for example http://localhost:4566)" >&2 + exit 1 +fi +mise x --env test -- cargo nextest run --locked -p stack-kms-aws --all-features --test localstack --no-tests=fail +""" diff --git a/packages/stack-kms-aws/tests/common/mod.rs b/packages/stack-kms-aws/tests/common/mod.rs new file mode 100644 index 000000000..f64a265d0 --- /dev/null +++ b/packages/stack-kms-aws/tests/common/mod.rs @@ -0,0 +1,224 @@ +//! Shared test support: the fixture time and credentials, and an in-process +//! emulation of the AWS KMS JSON API. 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::{BTreeMap, 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, Encoding}; +use serde::de::DeserializeOwned; +use serde_json::{json, Value}; +use stack_kms_aws::{AwsKms, AwsKmsConfig, Clock, Credentials}; +// Each test binary uses a different part of the harness. +#[allow(unused_imports)] +pub use stack_kms_vendor::replay::{Fixture, FixtureRequest, FixtureResponse, Fixtures, Replay}; +use stack_kms_vendor::{TransportError, VendorRequest, VendorResponse, VendorTransport}; +use web_time::{SystemTime, UNIX_EPOCH}; + +/// The key the fixtures use. +pub const KEY_ARN: &str = + "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab"; +/// 2026-11-05T12:00:00Z, the signing time of every fixture. +pub const FIXTURE_TIME: u64 = 1_793_880_000; + +pub fn fixture_clock() -> Clock { + Arc::new(|| UNIX_EPOCH + Duration::from_secs(FIXTURE_TIME)) +} + +/// The credentials of every fixture: the AWS documentation example key. +pub fn fixture_credentials() -> Credentials { + Credentials::new( + "AKIDEXAMPLE", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + None, + ) +} + +/// 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) +} + +/// A client over `transport` with the fixture time and credentials. +pub fn fixture_client(transport: T) -> AwsKms { + AwsKms::new( + AwsKmsConfig::new("us-east-1").unwrap(), + transport, + fixture_credentials(), + ) + .with_clock(fixture_clock()) +} + +/// An in-process emulation of the AWS KMS JSON API: `CreateKey`, `Encrypt` +/// and `Decrypt`, with the encryption context +/// bound by AES-256-GCM, and the AWS error shapes. It checks that each +/// request is signed for the fixture access key and the `kms` service. +/// +/// The contract suite uses random keys and ids, so recorded bytes cannot +/// serve it: this answers it instead. Clones share one key store. +#[derive(Clone, Default)] +pub struct KmsEmulator { + keys: Arc>>, + sleeps: Arc>>, +} + +impl KmsEmulator { + pub fn with_key(key_id: &str) -> Self { + let emulator = Self::default(); + emulator + .keys + .lock() + .unwrap() + .insert(key_id.to_owned(), rand_key()); + emulator + } + + pub fn sleeps(&self) -> Vec { + self.sleeps.lock().unwrap().clone() + } + + fn error(code: &str, message: &str) -> VendorResponse { + VendorResponse::new( + 400, + serde_json::to_vec(&json!({"__type": code, "message": message})).unwrap(), + ) + .header("content-type", "application/x-amz-json-1.1") + } + + fn ok(body: Value) -> VendorResponse { + VendorResponse::new(200, serde_json::to_vec(&body).unwrap()) + .header("content-type", "application/x-amz-json-1.1") + } + + fn seal(key_id: &str, key: &[u8; 32], context: &Value, plaintext: &[u8]) -> Vec { + let cipher = Aes256Gcm::new_from_slice(key).unwrap(); + let nonce = rand_key(); + let aad = serde_json::to_vec(context).unwrap(); + let sealed = cipher + .encrypt( + Nonce::from_slice(&nonce[..12]), + Payload { + msg: plaintext, + aad: &aad, + }, + ) + .unwrap(); + let mut blob = vec![key_id.len() as u8]; + blob.extend_from_slice(key_id.as_bytes()); + blob.extend_from_slice(&nonce[..12]); + blob.extend_from_slice(&sealed); + blob + } + + fn handle(&self, operation: &str, body: &Value) -> VendorResponse { + let context = body.get("EncryptionContext").cloned().unwrap_or(json!({})); + // A BTreeMap gives one byte form per context. + let context: BTreeMap = serde_json::from_value(context).unwrap(); + let context = serde_json::to_value(context).unwrap(); + let key_id = body.get("KeyId").and_then(Value::as_str).unwrap_or(""); + let keys = self.keys.lock().unwrap(); + match operation { + "CreateKey" => { + drop(keys); + let id = format!("emulated-{}", self.keys.lock().unwrap().len()); + self.keys.lock().unwrap().insert(id.clone(), rand_key()); + Self::ok(json!({"KeyMetadata": {"KeyId": id, "Arn": id}})) + } + "Encrypt" => { + let Some(key) = keys.get(key_id) else { + return Self::error("NotFoundException", "no such key"); + }; + let encoded = body.get("Plaintext").and_then(Value::as_str).unwrap(); + let plaintext = Base64::decode_vec(encoded).unwrap(); + let blob = Self::seal(key_id, key, &context, &plaintext); + Self::ok(json!({ + "KeyId": key_id, + "CiphertextBlob": Base64::encode_string(&blob), + })) + } + "Decrypt" => { + let blob = body + .get("CiphertextBlob") + .and_then(Value::as_str) + .and_then(|blob| Base64::decode_vec(blob).ok()) + .unwrap_or_default(); + let Some((&len, rest)) = blob.split_first() else { + return Self::error("InvalidCiphertextException", "empty"); + }; + let len = usize::from(len); + if rest.len() < len + 12 { + return Self::error("InvalidCiphertextException", "short"); + } + let (blob_key, rest) = rest.split_at(len); + if blob_key != key_id.as_bytes() { + return Self::error("IncorrectKeyException", "another key"); + } + let Some(key) = keys.get(key_id) else { + return Self::error("NotFoundException", "no such key"); + }; + let (nonce, sealed) = rest.split_at(12); + let aad = serde_json::to_vec(&context).unwrap(); + match Aes256Gcm::new_from_slice(key).unwrap().decrypt( + Nonce::from_slice(nonce), + Payload { + msg: sealed, + aad: &aad, + }, + ) { + Ok(plaintext) => Self::ok(json!({ + "KeyId": key_id, + "Plaintext": Base64::encode_string(&plaintext), + })), + Err(_) => Self::error("InvalidCiphertextException", "does not open"), + } + } + other => Self::error("UnknownOperationException", other), + } + } +} + +fn rand_key() -> [u8; 32] { + use vitaminc::random::{Generatable, SafeRand}; + let mut rng = SafeRand::from_entropy().unwrap(); + <[u8; 32]>::random(&mut rng).unwrap() +} + +impl VendorTransport for KmsEmulator { + async fn send(&self, request: VendorRequest) -> Result { + assert_eq!(request.method, "POST"); + assert_eq!( + request.header_value("content-type"), + Some("application/x-amz-json-1.1") + ); + let authorization = request.header_value("authorization").unwrap_or(""); + assert!( + authorization.starts_with("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/") + && authorization.contains("/us-east-1/kms/aws4_request, SignedHeaders="), + "unsigned or wrongly scoped request: {authorization}" + ); + let operation = request + .header_value("x-amz-target") + .and_then(|target| target.strip_prefix("TrentService.")) + .expect("an X-Amz-Target of TrentService") + .to_owned(); + let body: Value = serde_json::from_slice(&request.body).unwrap(); + Ok(self.handle(&operation, &body)) + } + + async fn sleep(&self, duration: Duration) { + self.sleeps.lock().unwrap().push(duration); + } +} + +/// The time now, for tests that do not pin it. +pub fn now() -> SystemTime { + SystemTime::now() +} diff --git a/packages/stack-kms-aws/tests/contract.rs b/packages/stack-kms-aws/tests/contract.rs new file mode 100644 index 000000000..4528df6ac --- /dev/null +++ b/packages/stack-kms-aws/tests/contract.rs @@ -0,0 +1,55 @@ +//! The shared contract suite, run against the AWS KMS backend over an +//! in-process emulation of the AWS KMS JSON API. +//! +//! The suite generates random keys and keyset ids, so recorded exchanges +//! cannot answer it; the emulator does (see `common::KmsEmulator`). The +//! request shapes are pinned by `tests/fixtures.rs`, and the real service +//! is exercised by `tests/localstack.rs`. + +mod common; + +use common::{fixture_client, KmsEmulator, KEY_ARN}; +use stack_kms::{BackendErrorKind, Error, KeyMode, KeyVendor}; +use stack_kms_aws::encryption_context; +use stack_kms_vendor::derive::record_key_aad; +use stack_kms_vendor::{contract, KeyVendorBackend}; +use uuid::Uuid; +use vitaminc::protected::Protected; + +#[tokio::test] +async fn aws_kms_meets_the_contract() { + let emulator = KmsEmulator::with_key(KEY_ARN); + contract::run(|| fixture_client(emulator.clone()), KEY_ARN).await; +} + +#[tokio::test] +async fn a_wrapped_key_opens_only_under_its_own_context_and_key() { + let emulator = KmsEmulator::with_key(KEY_ARN); + let kms = fixture_client(emulator.clone()); + let aad = record_key_aad(Uuid::from_u128(1), KeyVendor::Aws, KeyMode::Derived); + let wrapped = kms + .wrap(KEY_ARN, &Protected::new([1u8; 32]), &aad) + .await + .unwrap(); + + let other = record_key_aad(Uuid::from_u128(2), KeyVendor::Aws, KeyMode::Derived); + assert_ne!(encryption_context(&aad), encryption_context(&other)); + let error = kms.unwrap(KEY_ARN, &wrapped, &other).await.unwrap_err(); + assert!(matches!( + error, + Error::Backend { + kind: BackendErrorKind::InvalidCiphertext, + .. + } + )); + + let error = kms.unwrap("no-such-key", &wrapped, &aad).await.unwrap_err(); + assert!(matches!( + error, + Error::Backend { + kind: BackendErrorKind::InvalidCiphertext, + .. + } + )); + assert_eq!(kms.vendor(), KeyVendor::Aws); +} diff --git a/packages/stack-kms-aws/tests/credentials.rs b/packages/stack-kms-aws/tests/credentials.rs new file mode 100644 index 000000000..c30369a28 --- /dev/null +++ b/packages/stack-kms-aws/tests/credentials.rs @@ -0,0 +1,425 @@ +//! The workload identity credential providers and the default chain, +//! against recorded responses of STS, the container endpoint and IMDS. + +#![cfg(not(target_arch = "wasm32"))] + +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use stack_kms::BackendErrorKind; +use stack_kms_aws::credentials::{ + CachedCredentials, CredentialsProvider, DefaultCredentialsSource, EcsCredentials, EnvLookup, + ImdsCredentials, WebIdentityCredentials, +}; +use stack_kms_vendor::{TransportError, VendorRequest, VendorResponse, VendorTransport}; +use web_time::UNIX_EPOCH; + +/// A transport that answers from a script and keeps every request. +#[derive(Clone, Debug, Default)] +struct Scripted { + responses: Arc>>, + sent: Arc>>, +} + +impl Scripted { + fn answering(responses: impl IntoIterator) -> Self { + let scripted = Self::default(); + scripted.responses.lock().unwrap().extend(responses); + scripted + } + + fn sent(&self) -> Vec { + self.sent.lock().unwrap().clone() + } +} + +impl VendorTransport for Scripted { + async fn send(&self, request: VendorRequest) -> Result { + self.sent.lock().unwrap().push(request); + self.responses + .lock() + .unwrap() + .pop_front() + .ok_or_else(|| TransportError("connection refused".into())) + } + + async fn sleep(&self, _duration: Duration) {} +} + +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()) +} + +/// A file holding `contents`, removed on drop. +struct TempFile(std::path::PathBuf); + +impl TempFile { + fn new(name: &str, contents: &str) -> Self { + let path = + std::env::temp_dir().join(format!("stack-kms-aws-{}-{name}", std::process::id())); + std::fs::write(&path, contents).unwrap(); + Self(path) + } + + fn path(&self) -> &str { + self.0.to_str().unwrap() + } +} + +impl Drop for TempFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +/// An STS `AssumeRoleWithWebIdentity` response, as STS sends it. +const STS_RESPONSE: &str = r#" + + system:serviceaccount:default:app + sts.amazonaws.com + + arn:aws:sts::111122223333:assumed-role/app/stack-kms-aws + AROACLKWSDQRAOEXAMPLE:stack-kms-aws + + + FwoGZXIvYXdzEXAMPLE&TOKEN + wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLEKEY + 2026-11-05T13:00:00Z + ASIAIOSFODNN7EXAMPLE + + app + arn:aws:iam::111122223333:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE + + + ad4156e9-bce1-11e2-82e6-6b6efEXAMPLE + +"#; + +/// The JSON credentials the container endpoint and IMDS return. +const JSON_CREDENTIALS: &str = r#"{ + "Code": "Success", + "LastUpdated": "2026-11-05T11:55:00Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAJSONEXAMPLE", + "SecretAccessKey": "json-secret", + "Token": "json-token", + "Expiration": "2026-11-05T18:00:00Z" +}"#; + +fn ok(body: &str) -> VendorResponse { + VendorResponse::new(200, body.as_bytes().to_vec()) +} + +#[tokio::test] +async fn irsa_assumes_the_role_with_the_token_file() { + let token = TempFile::new("irsa-token", "eyJhbGciOiJSUzI1NiJ9.token\n"); + let transport = Scripted::answering([ok(STS_RESPONSE)]); + let provider = WebIdentityCredentials::from_env_lookup( + &env(&[ + ("AWS_WEB_IDENTITY_TOKEN_FILE", token.path()), + ("AWS_ROLE_ARN", "arn:aws:iam::111122223333:role/app"), + ("AWS_ROLE_SESSION_NAME", "my session"), + ]), + transport.clone(), + Some("ap-southeast-2"), + ) + .unwrap(); + let credentials = provider.credentials().await.unwrap(); + assert_eq!(credentials.access_key_id(), "ASIAIOSFODNN7EXAMPLE"); + assert_eq!( + credentials.secret_access_key(), + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYzEXAMPLEKEY" + ); + assert_eq!( + credentials.session_token(), + Some("FwoGZXIvYXdzEXAMPLE&TOKEN") + ); + assert_eq!( + credentials.expires_at(), + Some(UNIX_EPOCH + Duration::from_secs(1_793_883_600)) + ); + + let sent = transport.sent(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].method, "POST"); + assert_eq!(sent[0].url, "https://sts.ap-southeast-2.amazonaws.com/"); + assert_eq!( + sent[0].header_value("content-type"), + Some("application/x-www-form-urlencoded; charset=utf-8") + ); + assert!( + sent[0].header_value("authorization").is_none(), + "AssumeRoleWithWebIdentity is not signed" + ); + assert_eq!( + std::str::from_utf8(&sent[0].body).unwrap(), + "Action=AssumeRoleWithWebIdentity\ + &RoleArn=arn%3Aaws%3Aiam%3A%3A111122223333%3Arole%2Fapp\ + &RoleSessionName=my%20session&Version=2011-06-15\ + &WebIdentityToken=eyJhbGciOiJSUzI1NiJ9.token" + ); +} + +#[tokio::test] +async fn irsa_fails_cleanly() { + assert!(WebIdentityCredentials::from_env_lookup( + &env(&[("AWS_ROLE_ARN", "arn")]), + Scripted::default(), + None + ) + .is_none()); + + let provider = WebIdentityCredentials::new( + Scripted::default(), + "arn:aws:iam::111122223333:role/app", + "/no/such/token/file", + None, + ); + let error = provider.credentials().await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + + let token = TempFile::new("irsa-denied", "token"); + let denied = VendorResponse::new( + 403, + b"SenderAccessDenied\ + Not authorized to perform sts:AssumeRoleWithWebIdentity" + .to_vec(), + ); + let transport = Scripted::answering([denied]); + let provider = WebIdentityCredentials::new(transport.clone(), "arn", token.path(), None) + .with_sts_endpoint("https://sts.example/"); + let error = provider.credentials().await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + assert_eq!(transport.sent()[0].url, "https://sts.example/"); + + let provider = WebIdentityCredentials::new(Scripted::default(), "arn", token.path(), None); + let error = provider.credentials().await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::Unavailable); +} + +#[tokio::test] +async fn the_container_endpoint_gives_credentials() { + let transport = Scripted::answering([ok(JSON_CREDENTIALS)]); + let provider = EcsCredentials::from_env_lookup( + &env(&[( + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "/v2/credentials/5c4f4e3a", + )]), + transport.clone(), + ) + .unwrap() + .unwrap(); + let credentials = provider.credentials().await.unwrap(); + assert_eq!(credentials.access_key_id(), "ASIAJSONEXAMPLE"); + assert_eq!(credentials.session_token(), Some("json-token")); + let sent = transport.sent(); + assert_eq!(sent[0].method, "GET"); + assert_eq!(sent[0].url, "http://169.254.170.2/v2/credentials/5c4f4e3a"); + assert!(sent[0].header_value("authorization").is_none()); +} + +#[tokio::test] +async fn eks_pod_identity_sends_the_token_from_its_file() { + let token = TempFile::new("pod-identity", "pod-identity-token\n"); + let transport = Scripted::answering([ok(JSON_CREDENTIALS)]); + let provider = EcsCredentials::from_env_lookup( + &env(&[ + ( + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "http://169.254.170.23/v1/credentials", + ), + ("AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", token.path()), + ("AWS_CONTAINER_AUTHORIZATION_TOKEN", "ignored"), + ]), + transport.clone(), + ) + .unwrap() + .unwrap(); + provider.credentials().await.unwrap(); + let sent = transport.sent(); + assert_eq!(sent[0].url, "http://169.254.170.23/v1/credentials"); + assert_eq!( + sent[0].header_value("authorization"), + Some("pod-identity-token") + ); + + let transport = Scripted::answering([ok(JSON_CREDENTIALS)]); + let provider = EcsCredentials::from_env_lookup( + &env(&[ + ( + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "https://creds.example/", + ), + ("AWS_CONTAINER_AUTHORIZATION_TOKEN", "static-token"), + ]), + transport.clone(), + ) + .unwrap() + .unwrap(); + provider.credentials().await.unwrap(); + assert_eq!( + transport.sent()[0].header_value("authorization"), + Some("static-token") + ); + + assert!( + EcsCredentials::from_env_lookup(&env(&[]), Scripted::default()) + .unwrap() + .is_none() + ); + assert!(EcsCredentials::from_env_lookup( + &env(&[( + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "http://evil.example/creds" + )]), + Scripted::default() + ) + .is_err()); +} + +#[tokio::test] +async fn imds_uses_a_session_token() { + let transport = Scripted::answering([ + ok("imds-session-token"), + ok("app-instance-role\n"), + ok(JSON_CREDENTIALS), + ]); + let provider = ImdsCredentials::from_env_lookup(&env(&[]), transport.clone()).unwrap(); + let credentials = provider.credentials().await.unwrap(); + assert_eq!(credentials.access_key_id(), "ASIAJSONEXAMPLE"); + assert_eq!( + credentials.expires_at(), + Some(UNIX_EPOCH + Duration::from_secs(1_793_901_600)) + ); + let sent = transport.sent(); + assert_eq!(sent.len(), 3); + assert_eq!( + (sent[0].method.as_str(), sent[0].url.as_str()), + ("PUT", "http://169.254.169.254/latest/api/token") + ); + assert_eq!( + sent[0].header_value("x-aws-ec2-metadata-token-ttl-seconds"), + Some("21600") + ); + assert_eq!( + sent[1].url, + "http://169.254.169.254/latest/meta-data/iam/security-credentials/" + ); + assert_eq!( + sent[2].url, + "http://169.254.169.254/latest/meta-data/iam/security-credentials/app-instance-role" + ); + for request in &sent[1..] { + assert_eq!(request.method, "GET"); + assert_eq!( + request.header_value("x-aws-ec2-metadata-token"), + Some("imds-session-token") + ); + } +} + +#[tokio::test] +async fn imds_can_be_moved_or_disabled() { + assert!(ImdsCredentials::from_env_lookup( + &env(&[("AWS_EC2_METADATA_DISABLED", "TRUE")]), + Scripted::default() + ) + .is_none()); + let transport = Scripted::answering([ok("t"), ok("role"), ok(JSON_CREDENTIALS)]); + let provider = ImdsCredentials::from_env_lookup( + &env(&[( + "AWS_EC2_METADATA_SERVICE_ENDPOINT", + "http://[fd00:ec2::254]/", + )]), + transport.clone(), + ) + .unwrap(); + provider.credentials().await.unwrap(); + assert_eq!( + transport.sent()[0].url, + "http://[fd00:ec2::254]/latest/api/token" + ); +} + +#[tokio::test] +async fn imds_failures_map_to_kinds() { + // Off EC2: no answer. + let provider = ImdsCredentials::new(Scripted::default(), "http://169.254.169.254"); + let error = provider.credentials().await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::Unavailable); + // No instance profile: an empty role list, or a 404. + let provider = ImdsCredentials::new(Scripted::answering([ok("t"), ok("")]), "http://imds"); + let error = provider.credentials().await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + let provider = ImdsCredentials::new( + Scripted::answering([ok("t"), VendorResponse::new(404, vec![])]), + "http://imds", + ); + let error = provider.credentials().await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::NotFound); + // IMDSv1 only (token refused). + let provider = ImdsCredentials::new( + Scripted::answering([VendorResponse::new(403, vec![])]), + "http://imds", + ); + let error = provider.credentials().await.unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); +} + +#[test] +fn the_chain_picks_the_source_the_environment_names() { + let pick = |pairs: &[(&str, &str)]| { + DefaultCredentialsSource::from_env_lookup(&env(pairs), Scripted::default(), None) + }; + assert!(matches!( + pick(&[ + ("AWS_ACCESS_KEY_ID", "AKID"), + ("AWS_SECRET_ACCESS_KEY", "s"), + ("AWS_ROLE_ARN", "arn"), + ("AWS_WEB_IDENTITY_TOKEN_FILE", "/t"), + ]), + Ok(DefaultCredentialsSource::Static(_)) + )); + assert!(matches!( + pick(&[ + ("AWS_ROLE_ARN", "arn"), + ("AWS_WEB_IDENTITY_TOKEN_FILE", "/t"), + ("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2"), + ]), + Ok(DefaultCredentialsSource::WebIdentity(_)) + )); + assert!(matches!( + pick(&[("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2")]), + Ok(DefaultCredentialsSource::Ecs(_)) + )); + assert!(matches!(pick(&[]), Ok(DefaultCredentialsSource::Imds(_)))); + let error = pick(&[("AWS_EC2_METADATA_DISABLED", "true")]).unwrap_err(); + assert_eq!(error.kind(), BackendErrorKind::AccessDenied); + assert!(pick(&[("AWS_CONTAINER_CREDENTIALS_FULL_URI", "http://evil.example/")]).is_err()); +} + +#[tokio::test] +async fn the_cached_chain_fetches_once_until_near_expiry() { + let transport = Scripted::answering([ok("t"), ok("role"), ok(JSON_CREDENTIALS)]); + let source = + DefaultCredentialsSource::from_env_lookup(&env(&[]), transport.clone(), None).unwrap(); + // 2026-11-05T12:00:00Z; the credentials expire at 18:00. + let cache = CachedCredentials::new(source) + .with_clock(Arc::new(|| UNIX_EPOCH + Duration::from_secs(1_793_880_000))); + for _ in 0..3 { + assert_eq!( + cache.credentials().await.unwrap().access_key_id(), + "ASIAJSONEXAMPLE" + ); + } + assert_eq!( + transport.sent().len(), + 3, + "one IMDS fetch: token, role, credentials" + ); +} diff --git a/packages/stack-kms-aws/tests/fixtures.rs b/packages/stack-kms-aws/tests/fixtures.rs new file mode 100644 index 000000000..3c843eca8 --- /dev/null +++ b/packages/stack-kms-aws/tests/fixtures.rs @@ -0,0 +1,199 @@ +//! Each AWS KMS call and each error mapping, against recorded HTTP +//! exchanges (`tests/fixtures/*.json`). +//! +//! Every fixture request is signed at 2026-11-05T12:00:00Z with the AWS +//! documentation example key, so its bytes, signature included, are fixed. +//! The SigV4 algorithm itself is tested against the AWS test suite in +//! `src/sigv4.rs`; these fixtures pin the KMS request shape on top of it. + +mod common; + +use common::{fixture_client, load, Fixture, FixtureResponse, Replay, KEY_ARN}; +use serde::Deserialize; +use stack_kms::{BackendErrorKind, Error, KeyMode, KeyVendor}; +use stack_kms_aws::{AwsKms, AwsKmsConfig, Credentials, CredentialsError, CredentialsProvider}; +use stack_kms_vendor::derive::record_key_aad; +use stack_kms_vendor::{ + KeyVendorBackend, TransportError, VendorRequest, VendorResponse, VendorTransport, +}; +use uuid::Uuid; +use vitaminc::protected::{Controlled, Protected}; + +const KEYSET: Uuid = Uuid::from_u128(0x0f1e2d3c_4b5a_6978_8796_a5b4c3d2e1f0); + +fn record_aad() -> Vec { + record_key_aad(KEYSET, KeyVendor::Aws, KeyMode::Derived) +} + +fn kind_of(error: Error) -> BackendErrorKind { + match error { + Error::Backend { backend, kind, .. } => { + assert_eq!(backend.name(), "aws-kms"); + kind + } + other => panic!("not a backend error: {other:?}"), + } +} + +/// The ciphertext blob of a fixture's response. +fn blob_of(fixture: &str) -> Vec { + let fixture: Fixture = load(fixture); + let blob = fixture.response.body["CiphertextBlob"].as_str().unwrap(); + ::decode_vec(blob).unwrap() +} + +#[tokio::test] +async fn encrypt() { + let transport = Replay::new().expect(load("encrypt.json")); + let kms = fixture_client(transport); + let wrapped = kms + .wrap(KEY_ARN, &Protected::new([0x42u8; 32]), &record_aad()) + .await + .unwrap(); + assert_eq!(wrapped, blob_of("encrypt.json")); +} + +#[tokio::test] +async fn decrypt() { + let transport = Replay::new().expect(load("decrypt.json")); + let kms = fixture_client(transport); + let key = kms + .unwrap(KEY_ARN, &blob_of("encrypt.json"), &record_aad()) + .await + .unwrap(); + assert_eq!(key.risky_ref(), &[0x42u8; 32]); +} + +#[tokio::test] +async fn decrypt_with_a_session_token() { + let transport = Replay::new().expect(load("decrypt_with_session_token.json")); + let kms = AwsKms::new( + AwsKmsConfig::new("us-east-1").unwrap(), + transport, + Credentials::new( + "ASIAEXAMPLE", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + Some("IQoJb3JpZ2luX2VjEXAMPLESESSIONTOKEN".to_owned()), + ), + ) + .with_clock(common::fixture_clock()); + let key = kms + .unwrap(KEY_ARN, &blob_of("encrypt.json"), &record_aad()) + .await + .unwrap(); + assert_eq!(key.risky_ref(), &[0x42u8; 32]); +} + +#[derive(Deserialize)] +struct ErrorCase { + name: String, + response: FixtureResponse, + kind: String, +} + +#[tokio::test] +async fn every_recorded_error_maps_to_its_kind() { + let cases: Vec = load("errors.json"); + assert!(cases.len() >= 10); + for case in cases { + let kms = fixture_client(Replay::new().answer(case.response.to_response())); + let error = kms + .unwrap(KEY_ARN, b"blob", &record_aad()) + .await + .unwrap_err(); + let message = format!("{error:?}"); + assert_eq!( + format!("{:?}", kind_of(error)), + case.kind, + "{}: {message}", + case.name + ); + } +} + +/// A transport that never gets a response. +struct Down; + +impl VendorTransport for Down { + async fn send(&self, _request: VendorRequest) -> Result { + Err(TransportError("connection refused".into())) + } + + async fn sleep(&self, _duration: std::time::Duration) {} +} + +#[tokio::test] +async fn no_response_is_unavailable() { + let error = fixture_client(Down) + .unwrap(KEY_ARN, b"blob", &record_aad()) + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Unavailable); +} + +struct NoCredentials; + +impl CredentialsProvider for NoCredentials { + async fn credentials(&self) -> Result { + Err(CredentialsError::access_denied("none configured")) + } +} + +#[tokio::test] +async fn missing_credentials_are_access_denied_and_send_nothing() { + let kms = AwsKms::new( + AwsKmsConfig::new("us-east-1").unwrap(), + Replay::new(), + NoCredentials, + ); + let error = kms + .unwrap(KEY_ARN, b"blob", &record_aad()) + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::AccessDenied); +} + +#[tokio::test] +async fn an_unreadable_success_is_unavailable() { + for body in [ + b"not json".to_vec(), + br#"{"KeyId":"k"}"#.to_vec(), + br#"{"Plaintext":"not base64!"}"#.to_vec(), + ] { + let kms = fixture_client(Replay::new().answer(VendorResponse::new(200, body))); + let error = kms + .unwrap(KEY_ARN, b"blob", &record_aad()) + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Unavailable); + } + let kms = fixture_client( + Replay::new().answer(VendorResponse::new(200, br#"{"KeyId":"k"}"#.to_vec())), + ); + let error = kms + .wrap(KEY_ARN, &Protected::new([1u8; 32]), b"a") + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::Unavailable); +} + +#[tokio::test] +async fn a_plaintext_that_is_not_a_256_bit_key_is_refused() { + // 16 bytes: an AES_128 data key, not one this crate asked for. + let body = br#"{"KeyId":"k","Plaintext":"AAECAwQFBgcICQoLDA0ODw=="}"#.to_vec(); + let kms = fixture_client(Replay::new().answer(VendorResponse::new(200, body))); + let error = kms + .unwrap(KEY_ARN, b"blob", &record_aad()) + .await + .unwrap_err(); + assert_eq!(kind_of(error), BackendErrorKind::InvalidCiphertext); +} + +#[tokio::test] +async fn the_backend_reports_aws_and_sleeps_on_its_transport() { + let emulator = common::KmsEmulator::default(); + let kms = fixture_client(emulator.clone()); + assert_eq!(kms.vendor(), KeyVendor::Aws); + kms.sleep(std::time::Duration::from_millis(50)).await; + assert_eq!(emulator.sleeps(), [std::time::Duration::from_millis(50)]); +} diff --git a/packages/stack-kms-aws/tests/fixtures/decrypt.json b/packages/stack-kms-aws/tests/fixtures/decrypt.json new file mode 100644 index 000000000..6d3cf03cd --- /dev/null +++ b/packages/stack-kms-aws/tests/fixtures/decrypt.json @@ -0,0 +1,55 @@ +{ + "request": { + "body": { + "CiphertextBlob": "AQICAAoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2enw==", + "EncryptionContext": { + "cipherstash:aad": "BQAAAAAAAAAlAAAAAAAAAHN0YWNrLWttcy12ZW5kb3Iva2V5c2V0LXJlY29yZC1rZXkvdjEIAAAAAAAAAHJvb3Qta2V5EAAAAAAAAAAPHi08S1ppeIeWpbTD0uHwAQAAAAAAAAABAQAAAAAAAAAC", + "cipherstash:keyset-id": "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", + "cipherstash:purpose": "root-key" + }, + "KeyId": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab" + }, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amz-target", + "TrentService.Decrypt" + ], + [ + "host", + "kms.us-east-1.amazonaws.com" + ], + [ + "x-amz-date", + "20261105T120000Z" + ], + [ + "authorization", + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20261105/us-east-1/kms/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=5a7adc57c918d7c26c9f41a508fb8c1f5fe911ffa769764340d65c856118dd89" + ] + ], + "method": "POST", + "url": "https://kms.us-east-1.amazonaws.com/" + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amzn-requestid", + "8a3e7b2c-5f1d-4c9a-9e0b-1d2c3b4a5f60" + ] + ], + "body": { + "EncryptionAlgorithm": "SYMMETRIC_DEFAULT", + "KeyId": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", + "Plaintext": "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=" + } + } +} diff --git a/packages/stack-kms-aws/tests/fixtures/decrypt_with_session_token.json b/packages/stack-kms-aws/tests/fixtures/decrypt_with_session_token.json new file mode 100644 index 000000000..4b714b237 --- /dev/null +++ b/packages/stack-kms-aws/tests/fixtures/decrypt_with_session_token.json @@ -0,0 +1,59 @@ +{ + "request": { + "body": { + "CiphertextBlob": "AQICAAoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2enw==", + "EncryptionContext": { + "cipherstash:aad": "BQAAAAAAAAAlAAAAAAAAAHN0YWNrLWttcy12ZW5kb3Iva2V5c2V0LXJlY29yZC1rZXkvdjEIAAAAAAAAAHJvb3Qta2V5EAAAAAAAAAAPHi08S1ppeIeWpbTD0uHwAQAAAAAAAAABAQAAAAAAAAAC", + "cipherstash:keyset-id": "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", + "cipherstash:purpose": "root-key" + }, + "KeyId": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab" + }, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amz-target", + "TrentService.Decrypt" + ], + [ + "host", + "kms.us-east-1.amazonaws.com" + ], + [ + "x-amz-date", + "20261105T120000Z" + ], + [ + "x-amz-security-token", + "IQoJb3JpZ2luX2VjEXAMPLESESSIONTOKEN" + ], + [ + "authorization", + "AWS4-HMAC-SHA256 Credential=ASIAEXAMPLE/20261105/us-east-1/kms/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-security-token;x-amz-target, Signature=f90e655d194c3f323945f8c2787339ac775a677948ad13cd1f11ceba2ee7f157" + ] + ], + "method": "POST", + "url": "https://kms.us-east-1.amazonaws.com/" + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amzn-requestid", + "8a3e7b2c-5f1d-4c9a-9e0b-1d2c3b4a5f60" + ] + ], + "body": { + "EncryptionAlgorithm": "SYMMETRIC_DEFAULT", + "KeyId": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", + "Plaintext": "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=" + } + } +} diff --git a/packages/stack-kms-aws/tests/fixtures/encrypt.json b/packages/stack-kms-aws/tests/fixtures/encrypt.json new file mode 100644 index 000000000..36acce6fe --- /dev/null +++ b/packages/stack-kms-aws/tests/fixtures/encrypt.json @@ -0,0 +1,55 @@ +{ + "request": { + "body": { + "EncryptionContext": { + "cipherstash:aad": "BQAAAAAAAAAlAAAAAAAAAHN0YWNrLWttcy12ZW5kb3Iva2V5c2V0LXJlY29yZC1rZXkvdjEIAAAAAAAAAHJvb3Qta2V5EAAAAAAAAAAPHi08S1ppeIeWpbTD0uHwAQAAAAAAAAABAQAAAAAAAAAC", + "cipherstash:keyset-id": "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", + "cipherstash:purpose": "root-key" + }, + "KeyId": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", + "Plaintext": "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=" + }, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amz-target", + "TrentService.Encrypt" + ], + [ + "host", + "kms.us-east-1.amazonaws.com" + ], + [ + "x-amz-date", + "20261105T120000Z" + ], + [ + "authorization", + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20261105/us-east-1/kms/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=cac06e08f72133cfc264291ac6d5a9741083e93747b875f146883a4156ac581c" + ] + ], + "method": "POST", + "url": "https://kms.us-east-1.amazonaws.com/" + }, + "response": { + "status": 200, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amzn-requestid", + "8a3e7b2c-5f1d-4c9a-9e0b-1d2c3b4a5f60" + ] + ], + "body": { + "CiphertextBlob": "AQICAAoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2enw==", + "EncryptionAlgorithm": "SYMMETRIC_DEFAULT", + "KeyId": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab" + } + } +} diff --git a/packages/stack-kms-aws/tests/fixtures/errors.json b/packages/stack-kms-aws/tests/fixtures/errors.json new file mode 100644 index 000000000..d9c1a2aa1 --- /dev/null +++ b/packages/stack-kms-aws/tests/fixtures/errors.json @@ -0,0 +1,222 @@ +[ + { + "name": "ThrottlingException", + "response": { + "status": 400, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amzn-requestid", + "0f9c1b2a-3d4e-4f50-8a6b-7c8d9e0f1a2b" + ] + ], + "body": { + "__type": "ThrottlingException", + "message": "You have exceeded the rate at which you may call KMS. Reduce the frequency of your calls." + } + }, + "kind": "Throttled" + }, + { + "name": "AccessDeniedException", + "response": { + "status": 400, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amzn-requestid", + "0f9c1b2a-3d4e-4f50-8a6b-7c8d9e0f1a2b" + ] + ], + "body": { + "__type": "AccessDeniedException", + "Message": "User: arn:aws:iam::111122223333:user/app is not authorized to perform: kms:Decrypt on resource: arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab because no identity-based policy allows the kms:Decrypt action" + } + }, + "kind": "AccessDenied" + }, + { + "name": "NotFoundException", + "response": { + "status": 400, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amzn-requestid", + "0f9c1b2a-3d4e-4f50-8a6b-7c8d9e0f1a2b" + ] + ], + "body": { + "__type": "NotFoundException", + "message": "Key 'arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab' does not exist" + } + }, + "kind": "NotFound" + }, + { + "name": "InvalidCiphertextException", + "response": { + "status": 400, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amzn-requestid", + "0f9c1b2a-3d4e-4f50-8a6b-7c8d9e0f1a2b" + ] + ], + "body": { + "__type": "InvalidCiphertextException" + } + }, + "kind": "InvalidCiphertext" + }, + { + "name": "IncorrectKeyException", + "response": { + "status": 400, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amzn-requestid", + "0f9c1b2a-3d4e-4f50-8a6b-7c8d9e0f1a2b" + ] + ], + "body": { + "__type": "IncorrectKeyException", + "message": "The key ID in the request does not identify a CMK that can perform this operation." + } + }, + "kind": "InvalidCiphertext" + }, + { + "name": "KMSInternalException", + "response": { + "status": 500, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amzn-requestid", + "0f9c1b2a-3d4e-4f50-8a6b-7c8d9e0f1a2b" + ] + ], + "body": { + "__type": "KMSInternalException", + "message": "An internal error occurred." + } + }, + "kind": "Unavailable" + }, + { + "name": "DependencyTimeoutException", + "response": { + "status": 503, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amzn-requestid", + "0f9c1b2a-3d4e-4f50-8a6b-7c8d9e0f1a2b" + ] + ], + "body": { + "__type": "DependencyTimeoutException", + "message": "The system timed out while trying to fulfill the request." + } + }, + "kind": "Unavailable" + }, + { + "name": "a bare 503 from a load balancer", + "response": { + "status": 503, + "headers": [ + [ + "x-amzn-requestid", + "0f9c1b2a-3d4e-4f50-8a6b-7c8d9e0f1a2b" + ] + ], + "body": "Service Unavailable" + }, + "kind": "Unavailable" + }, + { + "name": "UnrecognizedClientException", + "response": { + "status": 400, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amzn-requestid", + "0f9c1b2a-3d4e-4f50-8a6b-7c8d9e0f1a2b" + ] + ], + "body": { + "__type": "UnrecognizedClientException", + "message": "The security token included in the request is invalid." + } + }, + "kind": "AccessDenied" + }, + { + "name": "the code in the x-amzn-ErrorType header only", + "response": { + "status": 400, + "headers": [ + [ + "x-amzn-requestid", + "0f9c1b2a-3d4e-4f50-8a6b-7c8d9e0f1a2b" + ], + [ + "x-amzn-ErrorType", + "ThrottlingException:http://internal.amazon.com/coral/com.amazon.coral.availability/" + ] + ], + "body": "" + }, + "kind": "Throttled" + }, + { + "name": "a namespaced __type", + "response": { + "status": 400, + "headers": [ + [ + "content-type", + "application/x-amz-json-1.1" + ], + [ + "x-amzn-requestid", + "0f9c1b2a-3d4e-4f50-8a6b-7c8d9e0f1a2b" + ] + ], + "body": { + "__type": "com.amazonaws.kms#NotFoundException", + "message": "Alias 'alias/missing' is not found." + } + }, + "kind": "NotFound" + } +] diff --git a/packages/stack-kms-aws/tests/localstack.rs b/packages/stack-kms-aws/tests/localstack.rs new file mode 100644 index 000000000..217b204e5 --- /dev/null +++ b/packages/stack-kms-aws/tests/localstack.rs @@ -0,0 +1,100 @@ +//! The AWS KMS backend against LocalStack, over the native transport. +//! +//! Runs only when `STACK_KMS_AWS_LOCALSTACK_ENDPOINT` is set (for example +//! `http://localhost:4566`); otherwise each test passes at once and says it +//! was skipped. CI runs LocalStack as a service and sets the variable. +//! +//! ```sh +//! docker run -d --rm -p 4566:4566 -e SERVICES=kms localstack/localstack:4.4 +//! STACK_KMS_AWS_LOCALSTACK_ENDPOINT=http://localhost:4566 \ +//! cargo nextest run -p stack-kms-aws --all-features --test localstack +//! ``` + +#![cfg(not(target_arch = "wasm32"))] + +use serde_json::Value; +use stack_kms::{BackendErrorKind, Error, KeyMode, KeyVendor}; +use stack_kms_aws::{AwsKms, AwsKmsConfig, Credentials}; +use stack_kms_vendor::derive::record_key_aad; +use stack_kms_vendor::{contract, HttpVendorTransport, KeyVendorBackend}; +use uuid::Uuid; +use vitaminc::protected::Protected; +use zeroize::Zeroizing; + +const ENDPOINT_VAR: &str = "STACK_KMS_AWS_LOCALSTACK_ENDPOINT"; + +type Client = AwsKms; + +fn client(endpoint: &str) -> Client { + let config = AwsKmsConfig::new("us-east-1") + .unwrap() + .with_endpoint(endpoint) + .unwrap(); + // LocalStack accepts any credentials; these are its documented ones. + AwsKms::new( + config, + HttpVendorTransport::new().unwrap(), + Credentials::new("test", "test", None), + ) +} + +fn endpoint() -> Option { + let endpoint = std::env::var(ENDPOINT_VAR).ok().filter(|e| !e.is_empty()); + if endpoint.is_none() { + eprintln!("skipped: set {ENDPOINT_VAR} to run against LocalStack"); + } + endpoint +} + +/// A new symmetric AWS KMS key in LocalStack; its ARN. +async fn create_key(kms: &Client) -> String { + let body = br#"{"KeySpec":"SYMMETRIC_DEFAULT","KeyUsage":"ENCRYPT_DECRYPT","Description":"stack-kms-aws test"}"#; + let created: Value = kms + .send_json("CreateKey", Zeroizing::new(body.to_vec())) + .await + .unwrap(); + created["KeyMetadata"]["Arn"].as_str().unwrap().to_owned() +} + +#[tokio::test] +async fn aws_kms_meets_the_contract_on_localstack() { + let Some(endpoint) = endpoint() else { return }; + let key_arn = create_key(&client(&endpoint)).await; + contract::run(|| client(&endpoint), &key_arn).await; +} + +#[tokio::test] +async fn localstack_errors_map_to_kinds() { + let Some(endpoint) = endpoint() else { return }; + let kms = client(&endpoint); + let key_arn = create_key(&kms).await; + let aad = record_key_aad(Uuid::new_v4(), KeyVendor::Aws, KeyMode::Derived); + let key = Protected::new([3u8; 32]); + let wrapped = kms.wrap(&key_arn, &key, &aad).await.unwrap(); + + let kind = |error: Error| match error { + Error::Backend { kind, .. } => kind, + other => panic!("{other:?}"), + }; + + // Another encryption context. + let other = record_key_aad(Uuid::new_v4(), KeyVendor::Aws, KeyMode::Derived); + let error = kms.unwrap(&key_arn, &wrapped, &other).await.unwrap_err(); + assert_eq!(kind(error), BackendErrorKind::InvalidCiphertext); + + // A damaged ciphertext. (LocalStack answers bytes that are not its + // blob format at all with HTTP 500, not InvalidCiphertextException, so + // the last byte of a real blob is changed instead. The fixtures cover + // the AWS answer.) + let mut damaged = wrapped.clone(); + if let Some(last) = damaged.last_mut() { + *last ^= 1; + } + let error = kms.unwrap(&key_arn, &damaged, &aad).await.unwrap_err(); + assert_eq!(kind(error), BackendErrorKind::InvalidCiphertext); + + // A key that does not exist. + let missing = "arn:aws:kms:us-east-1:000000000000:key/00000000-0000-0000-0000-000000000000"; + let error = kms.wrap(missing, &key, &aad).await.unwrap_err(); + assert_eq!(kind(error), BackendErrorKind::NotFound); +} diff --git a/release-plz.toml b/release-plz.toml index bde4a1f59..91b8268d7 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. # -# One more crate is publishable but STAGED: `stack-kms-vendor`, the `stack-kms-vendor` version group (the key vendor seam +# Two more crates are publishable but STAGED: `stack-kms-vendor` and `stack-kms-aws`, 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 @@ -93,3 +93,10 @@ name = "stack-kms-vendor" version_group = "stack-kms-vendor" release = false publish = false + +# Staged: see the header. +[[package]] +name = "stack-kms-aws" +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 a571a5868..056ff1486 100644 --- a/scripts/__tests__/cargo-publish-opt-out.test.mjs +++ b/scripts/__tests__/cargo-publish-opt-out.test.mjs @@ -57,9 +57,9 @@ 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 - // is publishable but staged: release-plz.toml keeps it - // from releasing until its first version is published by hand (see + // the Go guests' internal ABI and stays unpublished. stack-kms-vendor and stack-kms-aws + // are publishable but staged: release-plz.toml keeps them + // from releasing until their first version is published by hand (see // release-plz-root-config.test.mjs). root: '.', publishable: new Set([ @@ -69,6 +69,7 @@ const WORKSPACES = [ 'packages/stack-encrypt', 'packages/stack-encrypt-derive', 'packages/stack-kms-vendor', + 'packages/stack-kms-aws', ]), expects: 'packages/stack-auth', }, diff --git a/scripts/__tests__/crates-ci.test.mjs b/scripts/__tests__/crates-ci.test.mjs index feb8495d3..a33ca8ae4 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 six crate `tasks.toml` files the + * 1. Every mise task the set defines — the seven 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,13 +281,14 @@ const CI_EXEMPT_TASKS = new Map([ ]) describe('the scan sees every task mise sees', () => { - it('reads the six crate task files from the root mise.toml', () => { + it('reads the seven crate task files from the root mise.toml', () => { expect(INCLUDES.sort()).toEqual( [ 'packages/stack-auth/tasks.toml', 'packages/stack-encrypt/tasks.toml', 'packages/stack-guest-abi/tasks.toml', 'packages/stack-kms/tasks.toml', + 'packages/stack-kms-aws/tasks.toml', 'packages/stack-kms-vendor/tasks.toml', 'packages/stack-profile/tasks.toml', ].sort(), @@ -338,6 +339,7 @@ describe('the scan sees every task mise sees', () => { 'doc:stack-encrypt', 'doc:stack-guest-abi', 'doc:stack-kms', + 'doc:stack-kms-aws', 'doc:stack-kms-vendor', 'doc:stack-profile', ].sort(), diff --git a/scripts/__tests__/release-plz-root-config.test.mjs b/scripts/__tests__/release-plz-root-config.test.mjs index 1eb495171..fcfdc7136 100644 --- a/scripts/__tests__/release-plz-root-config.test.mjs +++ b/scripts/__tests__/release-plz-root-config.test.mjs @@ -45,6 +45,7 @@ const PUBLISHED = Object.keys(GROUPS).sort() // not fail trying. Arming one moves it from here to GROUPS. const STAGED = { 'stack-kms-vendor': 'stack-kms-vendor', + 'stack-kms-aws': 'stack-kms-vendor', } const PUBLISHABLE = [...PUBLISHED, ...Object.keys(STAGED)].sort()