diff --git a/.cargo-crap.toml b/.cargo-crap.toml new file mode 100644 index 000000000..a24c96def --- /dev/null +++ b/.cargo-crap.toml @@ -0,0 +1,23 @@ +# Configuration for `cargo crap` — the CRAP (Change Risk Anti-Patterns) metric. +# +# CRAP rewards complex code that is well tested and penalises complex code that +# is not: +# +# CRAP = CC^2 * (1 - coverage)^3 + CC (CC = cyclomatic complexity) +# +# A simple or fully covered function scores roughly its complexity; a complex, +# untested one scores into the hundreds. It surfaces exactly the kind of risky, +# under-tested logic where a subtle mistake can hide (see CIP-3233 / #2036). +# +# Run it via `mise run crap:stack-auth` (generates coverage first, then scores). +# Config discovery walks up from the working directory, so this single root file +# applies anywhere in the workspace. + +# CRAP score above which a function is flagged for refactoring or more tests. +# 30 is the long-standing Crap4J default: a CC-10 function needs ~42% coverage, +# a CC-15 function ~59%, to fall below it. +threshold = 30 + +# Functions with complexity but no coverage data are scored as 0% covered +# (worst case) rather than silently skipped — uninstrumented code is risk too. +missing = "pessimistic" diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..ade62e0db --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +# Wasm target needs the `wasm_js` getrandom backend (used by deps that pull +# `getrandom >= 0.3`, e.g. `rand 0.9+` via `vitaminc-random`). Without this +# rustflag, those crates fail to compile on wasm32-unknown-unknown — the +# stack-auth-wasm build (`languages/typescript/packages/stack-auth-wasm`). +# See: https://docs.rs/getrandom/latest/getrandom/#opt-in-backends +[target.wasm32-unknown-unknown] +rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 000000000..c15952b75 --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,81 @@ +# Configuration for cargo-mutants — mutation testing for the stack crates. +# +# Mutation testing rewrites small pieces of logic (flip a `<` to `<=`, replace +# a body with `Default::default()`, drop an `&&` arm) and reruns the tests: a +# mutant that survives is a line the suite does not actually pin down. CRAP +# (`crap:*`) says which complex code is uncovered; this says which covered +# code is not asserted on — in a crypto crate, the comparisons, length checks +# and context bindings a test can execute without ever checking. +# +# CI runs this `--in-diff` as a per-PR gate (.github/workflows/mutants.yml): +# only the lines a PR changes are mutated, so the gate trips when a PR adds +# logic its tests do not exercise. A full per-crate sweep is +# `mise run mutants:` (stack-auth ~15 min, stack-encrypt ~60 min on a +# laptop with four jobs); `mise run mutants` runs every crate that has one. +# Both read the settings below, so they stay in sync. + +# Build and test with every feature on, so feature-gated code (stack-encrypt's +# `dynamic` module, the `http` transports) is compiled and exercised. Without +# this a mutant there would "survive" only because the feature was off. +additional_cargo_args = ["--all-features"] + +# nextest, as everywhere else in the suite. The filterset drops two things +# from the per-mutant test command that are slow and pin no mutant: +# stack-encrypt's trybuild UI suite (`binary(ui)`: it compiles the derive's +# compile-fail cases in a scratch project, ~60s, and exercises no mutable +# line) and stack-auth's wall-clock stress tests (real servers, real sleeps, +# flaky under a slowed build). A filter naming something a package does not +# have matches nothing, so one filter serves every package. +test_tool = "nextest" +additional_cargo_test_args = ["-E", "not binary(ui) & not test(stress_tests)"] + +# Skip the derive crate: its logic runs inside `#[proc_macro_derive]` entry +# points at compile time, not in the instrumented test binary, so every +# mutant there survives spuriously. Its behaviour is pinned by stack-encrypt's +# `tests/derive.rs` and the trybuild UI suite instead. +exclude_globs = ["packages/stack-encrypt-derive/**"] + +# Mutants no test binary can kill, by name. Every other survivor is a test to +# write, not a line to add here. Two kinds qualify: +# +# - Unreachable: code compiled out of the native `--all-features` test build +# (wasm32-only impls, the no-`http` fallback, the non-test reqwest client). +# A mutant there builds and "survives" because nothing it touches is run. +# - Equivalent: the replacement is the value the code already returns +# (`Some(())` for a `()` credential, `Map::new()` for `Default::default()`, +# a builder's `new()` for its `Default`), so no test can tell them apart. +# +# The regexes match the name `--list` prints, `path:line:col: replace …`. +# Anchor on the replacement text, so an entry cannot swallow a reachable +# sibling with the same function name. The wasm32 `TokenStoreFn`, +# `AutoStrategy::detect_inner` and `Pending::into_future` entries also anchor +# on the line because their names are identical to the native impl's. +# If those lines move, a full sweep reports them again and the line numbers +# here need moving with them. +exclude_re = [ + # stack-auth — unreachable under the native test build. + 'stack-auth/src/transport\.rs:\d+:\d+: replace ::fmt ', + 'stack-auth/src/transport\.rs:\d+:\d+: replace ::send_dyn -> std::pin::Pin>\+\x27a>> ', + # Production http_client variants are cfg-disabled here; the test variant + # builds an unconfigured Client, equivalent to Client::default(). + 'stack-auth/src/transport\.rs:\d+:\d+: replace http_client -> reqwest::Client with Default::default\(\)$', + 'stack-auth/src/auto_strategy\.rs:150:9: replace AutoStrategy::detect_inner -> Result with Ok\(Default::default\(\)\)$', + 'stack-auth/src/token_store\.rs:(258|266):9: replace >::(load|save)', + # stack-auth — equivalent. + 'stack-auth/src/(access_key|oidc)_refresher\.rs:\d+:\d+: replace )?>::try_credential -> Option with Some\(Default::default\(\)\)$', + 'stack-auth/src/error\.rs:\d+:\d+: replace AuthErrorKind::payload -> serde_json::Map with Default::default\(\)$', + # stack-encrypt — unreachable under the native test build (the wasm32 + # variant; the native one returns a `FallbackKeyProvider`). + 'stack-encrypt/src/cipher\.rs:\d+:\d+: replace client_key_provider -> EnvKeyProvider with Default::default\(\)$', + 'stack-encrypt/src/target/pending\.rs:443:9: replace >::into_future -> Self::IntoFuture with Default::default\(\)$', + # stack-encrypt — equivalent. + 'stack-encrypt/src/cipher\.rs:\d+:\d+: replace StackCipher::builder -> StackCipherBuilder with Default::default\(\)$', + 'stack-encrypt/src/sem/mod\.rs:\d+:\d+: replace ::options -> MatchOptions with Default::default\(\)$', + # Both unit-context conversions explicitly return Self::default(). + 'stack-encrypt/src/target/context\.rs:\d+:\d+: replace for (DeclaredContext|ExpectedContext)>::from -> Self with Default::default\(\)$', +] + +# Headroom over the measured baseline before a slow-but-correct mutant is +# misreported as a timeout. +timeout_multiplier = 5.0 +minimum_test_timeout = 90 diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..130ee30f0 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,4 @@ +# Workflows set NEXTEST_PROFILE=ci. +[profile.ci] +# Do not cancel the test run on the first failure. +fail-fast = false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 671089d21..5831a661e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,3 +13,12 @@ /pnpm-lock.yaml @cipherstash/developers /.npmrc @cipherstash/developers /skills/stash-supply-chain-security/ @cipherstash/developers + +# The stack-* crates, their node bindings and the Go module, imported from +# cipherstash-suite. stack-auth, stack-profile and @cipherstash/auth publish to +# crates.io and npm. +/packages/stack-*/ @cipherstash/developers +/languages/golang/ @cipherstash/developers +/languages/typescript/packages/auth/ @cipherstash/developers +/languages/typescript/packages/profile/ @cipherstash/developers +/languages/typescript/packages/stack-auth-wasm/ @cipherstash/developers diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 395548f66..8c6073551 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -212,6 +212,74 @@ updates: update-types: - version-update:semver-major + # ── Cargo (the root workspace — the stack-* crates) ───────────── + # The root workspace (the six stack-* crates and the three node binding + # crates) and the five detached workspaces beside it: the three cargo-fuzz + # crates and the two Go WASI guests. Each has its own Cargo.lock, and each + # is its own workspace root, so each is listed. + - package-ecosystem: cargo + directories: + - / + - /packages/stack-auth/fuzz + - /packages/stack-kms/fuzz + - /packages/stack-encrypt/fuzz + - /languages/golang/stackencrypt/guest + - /languages/golang/stackauth/guest + # Monthly, matching the other two cargo entries. + schedule: + interval: monthly + cooldown: + default-days: 7 + open-pull-requests-limit: 3 + labels: + - dependencies + - supply-chain + commit-message: + prefix: "chore" + include: scope + groups: + cargo-minor-patch: + patterns: + - "*" + update-types: + - minor + - patch + ignore: + # Released from cipherstash-suite and pinned with exact `=` requirements + # in the root Cargo.toml and the guests: stack-auth's API carries their + # types, so they move in lockstep with the suite, by hand. + - dependency-name: "cts-common" + - dependency-name: "zerokms-protocol" + - dependency-name: "recipher" + - dependency-name: "cllw-ore" + # vitaminc is pinned at 0.5.0 across the crates and the guests; the + # guests' `FfiValue: Decrypt` bound fails if two versions resolve. + - dependency-name: "vitaminc*" + # Major bumps are reviewed and applied manually, not by Dependabot. + - dependency-name: "*" + update-types: + - version-update:semver-major + + # ── Go (languages/golang) ────────────────────────────────────── + - package-ecosystem: gomod + directory: /languages/golang + schedule: + interval: monthly + cooldown: + default-days: 7 + open-pull-requests-limit: 3 + labels: + - dependencies + - supply-chain + commit-message: + prefix: "chore" + include: scope + ignore: + # Major bumps are reviewed and applied manually, not by Dependabot. + - dependency-name: "*" + update-types: + - version-update:semver-major + # ── GitHub Actions ───────────────────────────────────────────── - package-ecosystem: github-actions directory: / diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1e311098f..90bbffe63 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -136,6 +136,15 @@ jobs: - name: Build the protect-ffi binding uses: ./.github/actions/build-ffi-binding + # `pnpm run test` below also runs the @cipherstash/auth and + # @cipherstash/profile vitest suites, which load the napi module. Their + # `test` scripts do not build it, so cargo stays off the default `test` + # path; this step builds it, as the protect-ffi step above does for + # `index.node`. Each `build:debug` writes its typings to the committed + # `native.d.ts`, so the build leaves the tree clean. + - name: Build the auth and profile node bindings + run: pnpm --filter @cipherstash/auth --filter @cipherstash/profile run build:debug + - name: Type tests (stack) run: pnpm exec turbo run test:types --filter @cipherstash/stack @@ -285,6 +294,13 @@ jobs: - name: Lint — no references to deleted package directories run: pnpm run lint:package-paths + # TEMPORARY — delete with the script in the arming PR (PR E) of the + # stack-* crates import. The seven @cipherstash/auth packages live here + # but still publish from cipherstash/cipherstash-suite, so a changeset + # naming one would bump a frozen package and block every release. + - name: Lint — no @cipherstash/auth changeset before the publishing cutover + run: pnpm run lint:auth-changeset + # `eql-bindings` emits EQL payloads; `@cipherstash/eql` carries the SQL # that stores them. Both live here now and release at one lockstep # version. A registry pin on either lets them drift apart — it compiles, diff --git a/.gitignore b/.gitignore index 9a2898f58..2a23c7916 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,22 @@ sql/cipherstash-*.sql .cipherstash/ notes/ + +# Rust crates imported from cipherstash-suite (packages/stack-*). +# cargo-fuzz: crash artifacts, coverage data, and the corpus that grows during +# a campaign. Ignore the generated corpus entries but keep the hand-written seed +# corpora (committed as `valid-*`) tracked. The fuzz crates' Cargo.lock files +# are tracked here, unlike in the suite, so `--locked` and Dependabot see them. +packages/*/fuzz/artifacts/ +packages/*/fuzz/coverage/ +packages/*/fuzz/corpus/*/* +!packages/*/fuzz/corpus/*/valid-* +# cargo-mutants output (`mise run mutants:` writes it to the cwd). +mutants.out/ + +# The Go module's embedded WASI guests: build outputs of `mise run +# wasm:guest:build` and `mise run wasm:auth-guest:build`. +languages/golang/stackencrypt/wasm/*.wasm +languages/golang/stackauth/wasm/*.wasm +languages/golang/stackencrypt/wasm/*.sha256 +languages/golang/stackauth/wasm/*.sha256 diff --git a/AGENTS.md b/AGENTS.md index 68acaf158..0999284fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ If these variables are missing, tests that require live encryption will fail or ## Repository Layout -Every npm package except EQL lives under `languages/typescript/`: packages in `languages/typescript/packages/`, example apps in `languages/typescript/examples/`. The root `packages/` holds EQL's subtree (and, later, Rust crates). The JavaScript root stays at the repository root: `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `.changeset/`, `biome.json`, `tsconfig.json` and `vitest.shared.ts`. +Every npm package except EQL lives under `languages/typescript/`: packages in `languages/typescript/packages/`, example apps in `languages/typescript/examples/`. The root `packages/` holds Rust crates only: the stack-* crates and EQL's subtree. The Go module is `languages/golang/`. The repository root is the root of the Cargo workspace and of mise (`Cargo.toml`, `Cargo.lock`, `mise.toml`), and the JavaScript root stays there too: `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `.changeset/`, `biome.json`, `tsconfig.json` and `vitest.shared.ts`. - `languages/typescript/packages/stack`: Main package (`@cipherstash/stack`) containing the encryption client and all integrations - Subpath exports: `@cipherstash/stack`, `@cipherstash/stack/identity`, `@cipherstash/stack/schema`, `@cipherstash/stack/eql/v3`, `@cipherstash/stack/v3`, `@cipherstash/stack/types`, `@cipherstash/stack/dynamodb`, `@cipherstash/stack/encryption`, `@cipherstash/stack/errors`, `@cipherstash/stack/adapter-kit`, `@cipherstash/stack/wasm-inline`, `@cipherstash/stack/diagnostics` (the Drizzle and Supabase integrations moved to their own packages — see below) @@ -90,6 +90,9 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l EQL issues in this repository, never in the historical `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), and `stack-kms`, `stack-encrypt`, `stack-encrypt-derive` 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". +- `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, **frozen here** until publishing moves from the suite (see `FROZEN_PUBLISHERS` below). `@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 (`stackencrypt`, `stackauth`, `internal`), a wazero host with no cgo. Its two WASI guests (`*/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; the `.wasm` files they embed are gitignored. There is no Go release process yet. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). @@ -97,8 +100,8 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l ## Working on protect-ffi -`languages/typescript/packages/protect-ffi` carries one of this repo's two Cargo workspaces (the -other is `packages/eql/crates`), and its scripts are split so a Rust toolchain +`languages/typescript/packages/protect-ffi` carries one of this repo's three Cargo workspaces (the +others are the root workspace, for the stack-* crates, and `packages/eql`), and its scripts are split so a Rust toolchain stays optional for everyone else. - **The default `test` and `build` never invoke cargo.** Root `pnpm test` runs @@ -554,18 +557,30 @@ monorepo, which is where the silent failures are. has been repointed, is likewise configuration — check the registry, do not read it here. This bullet used to narrate that state and was wrong twice. - **The map has been empty since EQL's Phase-5 cutover, and empty is a - legitimate state.** A package absorbed before its publisher moves goes back - in, with its artefact in `FROZEN_ARTEFACT_DIGESTS`, and both entries are - deleted in the PR that repoints its publisher — not afterwards. + **EQL left the map in its Phase-5 cutover, and an empty map is a legitimate + state.** A package absorbed before its publisher moves goes in, with its + artefact in `FROZEN_ARTEFACT_DIGESTS`, and both entries are deleted in the + PR that repoints its publisher — not afterwards. `scripts/__tests__/frozen-publisher-docs.test.mjs` holds this file, the EQL plan and `SECURITY.md`'s "Note on publishing" to the map — the last being the one file that tells a reporter which pipeline built the artefact they are reporting on. `release-gate.test.mjs` asserts the map carries neither EQL nor any FFI name: the seven protect-ffi packages were left in it after their own cutover, which armed the gate against the first release that - cutover had just enabled. With nothing frozen, the tests drive the mechanism - with EQL's old entry as an injected fixture. + cutover had just enabled. The tests drive the `field` mechanism with EQL's + old entry as an injected fixture. + + **Delete the `@cipherstash/auth*` entries in the arming PR of the stack-* + crates import.** The wrapper and its six platform packages, imported from + cipherstash-suite, are frozen the same way until npm trusted publishing is + repointed here. The wrapper has no release manifest, so its check-3 entry is + a `files` list: the gate hashes each of the 15 tracked files it publishes, + in the tree and in the tarball, and names the one that differs. That is why + `biome.json` excludes those files: a reformat is a skew, and the gate + refuses it. The platform packages publish only a binary built in CI, so their entries + declare `noTreeBytes` and check 3 skips them; checks 1 and 2 still apply. + `scripts/lint-no-auth-changeset.mjs` refuses a changeset naming any of the + seven, and goes in the same PR. **Check 3 is the one worth understanding before you touch a frozen package.** For a package this repo publishes, in-tree bytes differing from npm is an @@ -604,6 +619,89 @@ monorepo, which is where the silent failures are. subtree's own `.changeset/` was deleted with the import; there is no second one to put them in by mistake. +## Working on the Rust crates + +The stack-* crates came from `cipherstash/cipherstash-suite`, which still owns +`cipherstash-client`, `cts-common`, `zerokms-protocol`, `recipher` and +`cllw-ore`. Here those come from crates.io, pinned exactly in the root +`Cargo.toml`. + +- **Three Cargo workspaces, not one.** The root workspace (the six 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 and the two Go guests are detached workspaces too, each + with its own `Cargo.lock`. +- **The toolchain is pinned in the root `mise.toml`:** Rust 1.94.1 (the + `stack-encrypt` `tests/ui` trybuild snapshots record its diagnostics) and + Go 1.26. EQL's `mise.toml` overrides the Rust pin in its folder. + protect-ffi's does not pin Rust, so the root pin applies there. +- **The cargo tools are pinned in `mise.test.toml`, not `mise.toml`:** + nextest, llvm-cov, crap, mutants, fuzz and udeps. EQL and protect-ffi + inherit the root `mise.toml`, so a cargo tool there would be built in their + jobs too. Reach them with `mise x --env test -- …`, as the tasks do; CI jobs + that use them set `MISE_ENV: test`. +- **Run the tests with nextest, under the test env:** `mise x --env test -- + cargo nextest run --workspace --all-features`. Doc examples are `mise run + test:doc`; rustdoc with warnings as errors is `mise run doc`. +- **The node bindings keep cargo off `pnpm test`.** Their `test` runs vitest + and Biome against a binding already built with `pnpm --filter + @cipherstash/auth run build:debug`; `test:cargo` runs the crate's tests. + +### Fuzzing + +Untrusted-input parsers are fuzzed with cargo-fuzz / libFuzzer. The fuzz +crates live in `packages/*/fuzz/` (detached workspaces) and run via `fuzz:*` +mise tasks (nightly toolchain), e.g.: + +```bash +mise run fuzz:access-key # 60s default +mise run fuzz:access-key -- -max_total_time=300 +mise run fuzz:access-key -- -runs=0 # replay seed corpus only (CI regression) +``` + +CI is split into a blocking per-PR regression replay and a nightly, +non-blocking bug-finding campaign; the workflow arrives with the CI port. +Full walkthrough — layout, adding a target, the CI split — in +[`docs/fuzzing.md`](docs/fuzzing.md). For cargo-fuzz mechanics (sanitizers, +corpus, crash triage) use the Trail of Bits `cargo-fuzz` skill rather than a +repo-local one. + +### Miri + +The guest ABI crate (`packages/stack-guest-abi`) is the one place under +`languages/golang` that hands raw pointers to the Go host and rebuilds owned +buffers from them. Its unit tests run under Miri with strict provenance: + +```bash +mise run miri:stack-guest-abi # nightly + the miri component +``` + +Only the native half (the buffer registry, headers, status table) is +interpretable; the wasm32-only `abi` and `transport` modules read the wasm +`memory_size` intrinsic, so their hostile-input behaviour is pinned from the +Go side (`mise run go:test`). + +### Mutation testing + +The stack crates that opt in (stack-auth, stack-encrypt) are mutation-tested +with cargo-mutants; config in `.cargo/mutants.toml`. Once the CI port lands, +CI gates every PR touching them with `--in-diff`: only the lines the PR changes are mutated, and +a surviving mutant fails the job. A full sweep is slow and is run locally: + +```bash +mise run mutants:stack-auth # ~15 min +mise run mutants:stack-encrypt # ~60 min +mise run mutants # every crate with a mutants: task +``` + +A non-equivalent surviving mutant means the test suite does not distinguish +the changed behavior; the fix is a test that fails under that mutation, not +an exclusion. Exclude only what the configured test build cannot reach +(proc-macro entry points, wasm32-only modules), or a demonstrably equivalent +replacement such as `Some(Default::default())` for `Some(())`. Document the +reason and match the specific replacement so a reachable, behavior-changing +mutation in the same function stays covered. + ## Agent Skills — these ship to customers `skills/*/SKILL.md` are **published artifacts, not internal notes.** Treat a wrong diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8297ecabe..b10dc617c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -180,6 +180,18 @@ The `stash` / `@cipherstash/stack` / `@cipherstash/stack-drizzle` / packages are a `fixed` group in [`.changeset/config.json`](./.changeset/config.json): they always version together, so a bump to any one of them bumps all six. +`@cipherstash/auth` and its six `@cipherstash/auth-*` platform packages are +developed here but still published from `cipherstash/cipherstash-suite`. Until +publishing moves here, do not add a changeset for them: `pnpm run +lint:auth-changeset` fails on one, and `release:gate` blocks any version npm +does not have. Once publishing moves, the seven release together as their own +`fixed` group. + +Two Rust crates, `stack-auth` and `stack-profile`, are released to crates.io, +in one version group of their own, by release-plz from the root Cargo +workspace — not by Changesets. Until that pipeline is armed they, too, keep +releasing from the suite. + ## Pre-release process The 1.0 line published its `1.0.0-rc.*` series through diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..e5eb6615d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5127 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools 0.10.5", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "async-compression" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-mutex" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73112ce9e1059d8604242af62c7ec8e5975ac58ac251686c8403b45e8a6fe778" +dependencies = [ + "event-listener", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cached" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.5", + "once_cell", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cipherstash-config" +version = "0.42.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d098935e395d7346d0cdc8cdf3ed9674ab03fa8b415e828d02e65c81836a73c" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cllw-ore" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476f300d37a5029d3d9dd57145d4db50a23f40ae9b4d1374c44543978b906191" +dependencies = [ + "blake3", + "hex", + "subtle", + "thiserror 1.0.69", + "unicode-normalization", + "zeroize", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest 0.10.7", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-hex" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cts-common" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cb0f5ffa463e8facbe6ad78cfe925d132a051c6b1c9a5da2f3961296b7e632" +dependencies = [ + "arrayvec", + "base32", + "cached", + "chrono", + "derive_more", + "either", + "getrandom 0.4.2", + "miette", + "nom", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "utoipa", + "uuid", + "vitaminc", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", + "ctutils", + "zeroize", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dummy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fake" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" +dependencies = [ + "deunicode", + "dummy", + "rand 0.8.6", + "uuid", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix 0.38.44", + "windows-targets 0.52.6", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.1", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +dependencies = [ + "serde", +] + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.0", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mocktail" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053f7ba52863e22dfd2970075bbc69c4224ca6ae03896a5f69a0d5982deb5e0a" +dependencies = [ + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "prost", + "rand 0.9.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "mutants" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" + +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", + "tokio", +] + +[[package]] +name = "napi-build" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if", + "convert_case 0.6.0", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case 0.6.0", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn 2.0.114", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.3", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.0", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "recipher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e156e2d485b51cc67c19241e7d81ad524bda9fd4f77791b698ab10c8e26e9" +dependencies = [ + "aes", + "cmac", + "getrandom 0.2.17", + "hex", + "hex-literal", + "opaque-debug", + "rand 0.8.6", + "rand_chacha 0.3.1", + "serde", + "serde_cbor", + "sha2 0.10.9", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "hickory-resolver", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.21.1", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", + "zeroize", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.42.3" +dependencies = [ + "aquamarine", + "axum", + "base64", + "bytes", + "cts-common", + "jsonwebtoken", + "miette", + "mocktail", + "open", + "proptest", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "stack-profile", + "temp-env", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "web-time", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-auth-node" +version = "0.35.0" +dependencies = [ + "cts-common", + "jsonwebtoken", + "mocktail", + "napi", + "napi-build", + "napi-derive", + "serde_json", + "stack-auth", + "stack-profile", + "tempfile", + "tokio", + "url", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "stack-auth-wasm" +version = "0.35.0" +dependencies = [ + "base64", + "console_error_panic_hook", + "cts-common", + "js-sys", + "serde", + "serde-wasm-bindgen", + "serde_json", + "stack-auth", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "zeroize", +] + +[[package]] +name = "stack-encrypt" +version = "0.1.0" +dependencies = [ + "base64ct", + "cllw-ore", + "serde", + "serde_json", + "stack-auth", + "stack-encrypt-derive", + "stack-kms", + "thiserror 1.0.69", + "tokio", + "trybuild", + "uuid", + "vitaminc-aead", + "vitaminc-aead-value", + "vitaminc-encrypt", + "vitaminc-hmac", + "vitaminc-prf", + "vitaminc-protected", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-encrypt-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "stack-encrypt", + "stack-kms", + "syn 3.0.3", + "tokio", +] + +[[package]] +name = "stack-guest-abi" +version = "0.0.0" +dependencies = [ + "proptest", + "thiserror 1.0.69", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "stack-kms" +version = "0.1.0" +dependencies = [ + "async-mutex", + "base16ct", + "base64ct", + "blake3", + "futures", + "lazy_static", + "miette", + "opaque-debug", + "recipher", + "reqwest", + "serde", + "serde_cbor", + "serde_json", + "serdect", + "sha2 0.10.9", + "stack-auth", + "stack-profile", + "tempfile", + "thiserror 1.0.69", + "tokio", + "toml 0.8.23", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-profile" +version = "0.42.3" +dependencies = [ + "dirs", + "gethostname", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "stack-profile-node" +version = "0.35.0" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "stack-profile", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-triple" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" + +[[package]] +name = "temp-env" +version = "0.3.6" +source = "git+https://github.com/cipherstash/temp-env?branch=main#0223c1c07c40dbf0a621cc88dd3191a0917d9db7" +dependencies = [ + "lock_api", + "parking_lot", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "trybuild" +version = "1.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f614c21bd3a61bad9501d75cbb7686f00386c806d7f456778432c25cf86948a" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml 0.9.11+spec-1.1.0", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "url", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "atomic", + "getrandom 0.3.4", + "js-sys", + "md-5", + "rand 0.9.3", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vitaminc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +dependencies = [ + "vitaminc-aead", + "vitaminc-context", + "vitaminc-encrypt", + "vitaminc-protected", + "vitaminc-random", + "vitaminc-traits", +] + +[[package]] +name = "vitaminc-aead" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +dependencies = [ + "bytes", + "serde", + "vitaminc-aead-derive", + "vitaminc-context", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-aead-value" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +dependencies = [ + "vitaminc-aead", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-context" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +dependencies = [ + "mutants", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-hmac" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" +dependencies = [ + "hmac", + "sha2 0.11.0", + "vitaminc-prf", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-prf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" +dependencies = [ + "mutants", + "thiserror 2.0.18", + "vitaminc-context", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-protected" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.18", + "vitaminc-protected-derive", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-random" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random-derives", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-traits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45649196a53b0b7a15101d845d44d2dda7374fc1b5b5e2bbf58b7577ff4b346d" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f579cdd0123ac74b94e1a4a72bd963cf30ebac343f2df347da0b8df24cdebed2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8145dd1593bf0fb137dbfa85b8be79ec560a447298955877804640e40c2d6ea" + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerokms-protocol" +version = "0.12.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c28e88315a5109d0a1e7ee4b7b4b8776a0bff5f5b139ae83960a3debe84e92e" +dependencies = [ + "base64", + "cipherstash-config", + "const-hex", + "cts-common", + "fake", + "getrandom 0.2.17", + "opaque-debug", + "rand 0.8.6", + "serde", + "static_assertions", + "thiserror 1.0.69", + "utoipa", + "uuid", + "validator", + "zeroize", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..78d1e14e3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,130 @@ +[workspace] +resolver = "2" + +# The stack-* crates imported from cipherstash-suite, and the three node +# bindings that wrap them. The root is one of three Cargo workspaces in this +# repository: EQL and protect-ffi keep their own, and pin their own vitaminc +# (see `exclude`). +members = [ + "packages/stack-auth", + "packages/stack-profile", + "packages/stack-kms", + "packages/stack-encrypt", + "packages/stack-encrypt-derive", + "packages/stack-guest-abi", + "languages/typescript/packages/auth", + "languages/typescript/packages/profile", + "languages/typescript/packages/stack-auth-wasm", +] + +# Each of these is its own workspace, with its own Cargo.lock. +exclude = [ + "packages/eql", + "languages/typescript/packages/protect-ffi", + # cargo-fuzz crates: nightly-only, run through the `fuzz:*` mise tasks. + "packages/stack-auth/fuzz", + "packages/stack-kms/fuzz", + "packages/stack-encrypt/fuzz", + # WASI guests for the Go module, built through `wasm:guest:build` and + # `wasm:auth-guest:build`. + "languages/golang/stackencrypt/guest", + "languages/golang/stackauth/guest", +] + +[workspace.package] +# Used by the node binding crates, which take `version.workspace`. The +# published crates (stack-auth, stack-profile) carry their own version. +version = "0.35.0" +edition = "2021" +authors = [ + "Dan Draper ", + "Drew Thomas ", + "Fiona McCawley ", + "James Sadler ", + "Kate Andrews ", + "Lindsay Holmwood ", + "Paul Hawkins ", + "Robin Howard ", + "Toby Hede ", + "Yuji Yokoo ", +] +repository = "https://github.com/cipherstash/stack" +homepage = "https://cipherstash.com" +keywords = ["cryptography", "security", "databases", "encryption", "sql"] +categories = ["cryptography", "database"] + +# Note that profiles provided in any non-root packages are ignored +[profile.release] +strip = true # same as "symbols" +lto = true # same as "fat" + +[profile.dev] +incremental = true + +[workspace.dependencies] +# The two published crates of this workspace. Members take them with +# `workspace = true`. +# `default-features = false`: a member can add features to a workspace dep +# but cannot subtract them, and `stack-kms` / `stack-encrypt` need stack-auth +# without `http`. Consumers that want the default transport re-enable it with +# `features = ["http"]`. +stack-auth = { path = "./packages/stack-auth", version = "0.42.3", default-features = false } +stack-profile = { path = "./packages/stack-profile", version = "0.42.3" } + +# Suite crates, from crates.io; Cargo.lock holds the exact versions. +# cts-common and zerokms-protocol take caret requirements because the +# published stack-auth inherits them: an exact pin would stop the suite +# unifying stack-auth with a later compatible cts-common, and a second copy +# breaks the shared Crn, Region and WorkspaceId types. recipher and cllw-ore +# feed only unpublished crates, so they stay exact. +cts-common = { version = "0.43.0", default-features = false } +zerokms-protocol = "0.12.31" +recipher = "=0.3.1" +cllw-ore = { version = "=0.5.0", default-features = false } + +# External dependencies, with the suite's feature lists. +base64 = "0.22.0" +blake3 = { version = "1.5.4", features = ["zeroize"] } +jsonwebtoken = { version = "10.3.0", default-features = false, features = ["aws_lc_rs", "use_pem"] } +lazy_static = "1.4.0" +# Base miette (the `Diagnostic` derive). The `fancy` renderer is heavy and +# only needed by binaries; enable it per crate. +miette = { version = "7.5.0" } +reqwest = { version = "0.13", default-features = false, features = [ + "brotli", + "gzip", + "json", + "rustls", + "hickory-dns", + "stream", + "form", + "query", +] } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0.132" } +thiserror = "1.0.56" +tokio = { version = "1.47.1", features = ["full"] } +tracing = { version = "0.1", features = ["log"] } +tracing-subscriber = { version = "0.3", features = [ + "ansi", + "json", + "env-filter", + "std", +] } +url = { version = "2.5.4", features = ["serde"] } +uuid = { version = "1.8", features = ["v4", "v5", "serde"] } +# Drop-in `std::time::{Instant, SystemTime}` replacement — polyfills via JS +# time APIs on wasm32 (stdlib's wasm time module is a panicking stub). +web-time = "1.1" +zeroize = { version = "1.8.1", features = ["derive"] } +# This is needed because lock_api 0.4.6 was breaking things and the branch forces the use of 0.4.12 +temp-env = { git = "https://github.com/cipherstash/temp-env", branch = "main" } +vitaminc = { version = "0.5.0", features = ["random", "protected", "encrypt"] } +vitaminc-aead = "0.5.0" +# The dynamic value model (`FfiValue`) and its FFI transport codec, shared by +# every language binding. Optional in stack-encrypt (the `dynamic` feature). +vitaminc-aead-value = "0.5.0" +vitaminc-encrypt = "0.5.0" +vitaminc-hmac = "0.5.0" +vitaminc-prf = "0.5.0" +vitaminc-protected = "0.5.0" diff --git a/SECURITY.md b/SECURITY.md index 506e118e4..87bfec41c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,6 +19,8 @@ This repository is the CipherStash Stack monorepo for JavaScript/TypeScript. It | `@cipherstash/wizard` | AI-powered encryption setup | | `@cipherstash/protect-ffi` | Native FFI bindings to the CipherStash Client SDK — the Rust core `@cipherstash/stack` encrypts and decrypts through | | `@cipherstash/protect-ffi-darwin-arm64`
`@cipherstash/protect-ffi-darwin-x64`
`@cipherstash/protect-ffi-linux-arm64-gnu`
`@cipherstash/protect-ffi-linux-x64-gnu`
`@cipherstash/protect-ffi-linux-x64-musl`
`@cipherstash/protect-ffi-win32-x64-msvc` | Prebuilt per-platform binaries for `@cipherstash/protect-ffi`. Installed as optional dependencies; one is selected at load time for the host platform | +| `@cipherstash/auth` | Authentication strategies for CipherStash (napi-rs native binding, with a WASM build for edge runtimes) — imported from `cipherstash/cipherstash-suite` with the `stack-auth` crate it wraps | +| `@cipherstash/auth-darwin-arm64`
`@cipherstash/auth-darwin-x64`
`@cipherstash/auth-linux-arm64-gnu`
`@cipherstash/auth-linux-x64-gnu`
`@cipherstash/auth-linux-x64-musl`
`@cipherstash/auth-win32-x64-msvc` | Prebuilt per-platform binaries for `@cipherstash/auth`. Installed as optional peer dependencies; one is selected at load time for the host platform | | `@cipherstash/eql` | Encrypt Query Language — the PostgreSQL SQL bundle (`eql_v3` schema: domains, operators, index-term extractors) that stores and queries encrypted payloads, plus its generated TypeScript types. Applied by `stash eql install` and by the Prisma Next adapter's migrations. Released in lockstep with the `eql-bindings` Rust crate, which emits the payloads this SQL reads | This repository also carries the source of the **`eql-bindings`** Rust crate @@ -26,12 +28,23 @@ This repository also carries the source of the **`eql-bindings`** Rust crate lockstep with `@cipherstash/eql`. It is in scope for security reports on the same terms as the npm packages above. -> **Note on publishing.** Every package in the table above, including all seven -> `@cipherstash/protect-ffi*` packages and `@cipherstash/eql`, is published from -> this repository by `.github/workflows/release.yml`; the `eql-bindings` crate -> is published from here by `.github/workflows/release-plz.yml`. EQL moved here -> at the Phase 5 cutover in `docs/plans/2026-08-13-eql-monorepo-absorption.md`. -> Releases made before it, `@cipherstash/eql@3.0.5` and earlier, were built by +It also carries the source of two Rust crates published to crates.io, +**`stack-auth`** and **`stack-profile`** (`packages/stack-auth`, +`packages/stack-profile`), and of the **Go module** at `languages/golang` +(`stackencrypt` and `stackauth`, over WASI guests built from the stack-* +crates), which has no release yet. All three are in scope for security reports +on the same terms as the npm packages above. + +> **Note on publishing.** `@cipherstash/auth` and its six platform packages, +> and the `stack-auth` and `stack-profile` crates, are developed here but are +> *published* from `cipherstash/cipherstash-suite` until the arming PR of the +> stack-* crates import repoints them. Every other package in the table above, +> including all seven `@cipherstash/protect-ffi*` packages and +> `@cipherstash/eql`, is published from this repository by +> `.github/workflows/release.yml`; the `eql-bindings` crate is published from +> here by `.github/workflows/release-plz.yml`. EQL moved here at the Phase 5 +> cutover in `docs/plans/2026-08-13-eql-monorepo-absorption.md`. Releases made +> before it, `@cipherstash/eql@3.0.5` and earlier, were built by > `cipherstash/encrypt-query-language`. **Source, issues, and security reports > for all of them belong here regardless** — that part does not depend on which > pipeline built the artefact. diff --git a/biome.json b/biome.json index f20e4bb71..a70d1857d 100644 --- a/biome.json +++ b/biome.json @@ -16,6 +16,27 @@ "!packages/eql/release", "!packages/eql/target", "!packages/eql/docs/api", + "!target", + "!**/target", + "!languages/typescript/packages/auth/wasm", + "!languages/typescript/packages/auth/index.js", + "!languages/typescript/packages/auth/stack-auth-node.js", + "!languages/typescript/packages/auth/wasm-inline.mjs", + "!languages/typescript/packages/auth/cookies.mjs", + "!languages/typescript/packages/auth/base64url.mjs", + "!languages/typescript/packages/auth/next.mjs", + "!languages/typescript/packages/auth/index.d.ts", + "!languages/typescript/packages/auth/native.d.ts", + "!languages/typescript/packages/auth/wasm-types.d.ts", + "!languages/typescript/packages/auth/wasm-inline.d.ts", + "!languages/typescript/packages/auth/cookies.d.ts", + "!languages/typescript/packages/auth/base64url.d.ts", + "!languages/typescript/packages/auth/next.d.ts", + "!languages/typescript/packages/auth/README.md", + "!languages/typescript/packages/auth/LICENSE", + "!languages/typescript/packages/profile/index.d.ts", + "!languages/typescript/packages/profile/native.d.ts", + "!languages/golang", "!**/*.grit", "!**/*.generated.ts", "!**/contract.json", diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index 4bd5ecdae..4705bb4b4 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -6,12 +6,18 @@ operations. ## Repository ownership All work present in this monorepo is tracked in `cipherstash/stack`, including -the absorbed EQL source under `packages/eql` and protect-ffi under -`languages/typescript/packages/protect-ffi`. Their former upstream repositories are historical -sources, not active issue trackers. Never create, move, or update an issue in -`cipherstash/encrypt-query-language` or `cipherstash/protectjs-ffi` for work in -this tree. Create it in `cipherstash/stack` and link historical upstream issues -only as provenance. +the absorbed EQL source under `packages/eql`, protect-ffi under +`languages/typescript/packages/protect-ffi`, and the stack-* crates, node bindings and Go +module imported from `cipherstash/cipherstash-suite` (the stack-* crates under `packages/`, +`languages/typescript/packages/auth`, `languages/typescript/packages/profile`, +`languages/typescript/packages/stack-auth-wasm` and `languages/golang`). The +former upstream repositories of EQL and protect-ffi are historical sources, not +active issue trackers. `cipherstash/cipherstash-suite` is still active for the +crates that stayed there, but not for anything in this tree. Never create, +move, or update an issue in `cipherstash/encrypt-query-language`, +`cipherstash/protectjs-ffi` or `cipherstash/cipherstash-suite` for work in this +tree. Create it in `cipherstash/stack` and link historical upstream issues only +as provenance. ## Conventions diff --git a/docs/auth-strategy-handover.md b/docs/auth-strategy-handover.md new file mode 100644 index 000000000..6cd031ae4 --- /dev/null +++ b/docs/auth-strategy-handover.md @@ -0,0 +1,198 @@ +# Passing a JS-defined `AuthStrategy` to `protect-ffi` + +> **Status:** RFC. Cross-repo design — implementation lives in [`cipherstash/protectjs-ffi`](https://github.com/cipherstash/protectjs-ffi). +> +> **Prep landed in this repo:** [`stack_auth::AuthStrategyFn`](../packages/stack-auth/src/auth_strategy_fn.rs) — the helper protect-ffi will reach for. Sits on the acquisition layer ([`stack_auth::auth`](../packages/stack-auth/src/lib.rs)); its sibling [`stack_auth::TokenStoreFn`](../packages/stack-auth/src/token_store.rs) on the persistence layer is what `JsTokenStore` already uses for cookie-backed caching. + +## Why + +After PRs #1958 + #1959, JS consumers of `@cipherstash/auth` get a clean strategy primitive: + +```ts +const strategy = AccessKeyStrategy.create(region, accessKey, { + store: cookieStore({ request: req, responseHeaders }), +}); +``` + +But `@cipherstash/protect-ffi` (the encryption binding consumers actually use today) has its own auth wiring built into `cipherstash-client`'s init path. Two systems doing auth side-by-side, neither aware of the other. + +The eventual target state is a `stack-encrypt` crate with its own napi/wasm bindings that natively accept a `stack_auth::AuthStrategy`. That's a meaningful rewrite of the encryption surface and **is not what this RFC describes**. + +This RFC describes the smaller, incremental step: let JS consumers pass an `@cipherstash/auth` strategy through `protect-ffi` into the underlying `cipherstash-client`. `protect-ffi` becomes auth-agnostic — it knows the strategy has a `.getToken(): Promise` method, nothing more. + +## End-to-end flow + +``` +JS consumer + └─ AccessKeyStrategy.create(region, accessKey, { store: cookieStore(...) }) + │ + └─ passes the strategy object to protect-ffi: + newClient({ authStrategy: strategy, /* ...rest */ }) + │ + └─ protect-ffi (Neon, Rust side): + │ + ├─ wraps the JS callable in `JsAuthStrategy` adapter + ├─ `impl AuthStrategy for &JsAuthStrategy` + └─ hands it to `cipherstash_client::ZeroKMSBuilder::new(adapter)` + │ + └─ cipherstash-client (no changes here): + │ + └─ per HTTP request → `(&credentials).get_token().await` + │ + └─ adapter calls into JS via Neon Channel + │ + └─ strategy.getToken() → TokenResult + │ + └─ adapter wraps in ServiceToken + │ + └─ Authorization: Bearer +``` + +## JS API + +`protect-ffi`'s `newClient` gains an `authStrategy` option: + +```ts +import { AccessKeyStrategy } from "@cipherstash/auth"; +import { cookieStore } from "@cipherstash/auth/cookies"; +import { newClient } from "@cipherstash/protect-ffi"; + +const strategy = AccessKeyStrategy.create(region, accessKey, { + store: cookieStore({ request: req, responseHeaders }), +}); + +const client = await newClient({ + authStrategy: strategy, + // ...other protect-ffi options (workspace, region, etc) +}); +``` + +`protect-ffi` treats `authStrategy` as a black box. The contract is purely structural: **the value must have a `getToken(): Promise` method**. Anything that satisfies that — `AccessKeyStrategy`, a future `OAuthStrategy.create(...)`, a hand-rolled mock — works. + +This is intentionally not typed against `@cipherstash/auth`'s specific class. `protect-ffi` does not add a runtime dependency on `@cipherstash/auth`; consumers bring their own. + +## Rust-side adapter (in protect-ffi) + +The shape mirrors `JsTokenStore` from `packages/stack-auth/wasm/src/lib.rs:96-139` — different FFI substrate (Neon vs wasm-bindgen) but the same wrap-JS-callable-in-Rust-struct-and-impl-the-trait pattern. + +```rust +use neon::prelude::*; +use neon::types::{Deferred, JsObject, JsPromise}; +use stack_auth::{AuthError, AuthStrategy, SecretToken, ServiceToken}; + +/// Adapter that holds a JS `AccessKeyStrategy`-shaped object and surfaces +/// its `.getToken()` via the `AuthStrategy` trait. Used by +/// `cipherstash-client` (via `ZeroKMSBuilder::new`). +pub(crate) struct JsAuthStrategy { + /// Persistent handle to the JS strategy object (kept alive across + /// `cipherstash-client` calls). + strategy: Root, + /// Channel for scheduling work on the JS thread. + channel: Channel, +} + +impl JsAuthStrategy { + pub(crate) fn new(cx: &mut impl Context, strategy: Handle) -> Self { + Self { + strategy: strategy.root(cx), + channel: cx.channel(), + } + } +} + +impl AuthStrategy for &JsAuthStrategy { + fn get_token(self) -> impl Future> + Send { + // Build a oneshot to receive the JS result on the Rust async side. + let (tx, rx) = tokio::sync::oneshot::channel(); + let strategy = self.strategy.clone(/* on the JS thread */); + + // Schedule the JS call on the libuv main thread. + self.channel.send(move |mut cx| { + let strategy = strategy.into_inner(&mut cx); + let get_token: Handle = strategy.get(&mut cx, "getToken")?; + let promise: Handle = get_token.call(&mut cx, strategy, &[])?.downcast_or_throw(&mut cx)?; + + // Resolve the JS Promise, extract `token`, send to the Rust side. + let _ = promise.to_future(&mut cx, |mut cx, result| { + let result = result?; + let result: Handle = result.downcast_or_throw(&mut cx)?; + let token: Handle = result.get(&mut cx, "token")?; + let token = token.value(&mut cx); + let _ = tx.send(Ok(token)); + Ok(cx.undefined()) + }); + Ok(()) + }); + + async move { + let jwt: String = rx.await + .map_err(|_| AuthError::Server("JS strategy dropped before responding".into()))??; + Ok(ServiceToken::new(SecretToken::new(jwt))) + } + } +} +``` + +Then the protect-ffi `newClient` Neon function: + +1. Extracts `options.authStrategy` as a `JsObject`. +2. Builds `JsAuthStrategy::new(&mut cx, strategy)`. +3. Hands the adapter to `cipherstash_client::ZeroKMSBuilder::new(adapter)` (works because `&JsAuthStrategy: AuthStrategy` and the builder's bound is `for<'a> &'a C: AuthStrategy`). +4. Wraps the resulting client in whatever protect-ffi handle type Neon exposes to JS. + +**The exact Neon API details (`promise.to_future`, `Deferred`, etc.) need to be confirmed against the current Neon version used by protect-ffi** — the sketch above is illustrative, not literal. + +## FFI wire format + +`strategy.getToken()` (JS) returns a `TokenResult`: + +```ts +interface TokenResult { + token: string; // the JWT — the bearer credential + subject: string; // decoded claim + workspaceId: string; // decoded claim + issuer: string; // decoded claim + services: Record; // decoded claim +} +``` + +The Rust adapter pulls just `result.token` across the FFI and wraps it in `ServiceToken::new(SecretToken::new(token))`. Claim accessors (`.subject()`, `.workspace_id()`, `.services()`) re-decode on demand from the JWT payload — `cipherstash-client` mostly hits `.as_str()` for `Authorization` headers and only occasionally needs the claims (service discovery), so re-decoding is cheap. + +The redundant decode (JS already decoded the claims into `TokenResult` fields, Rust re-decodes lazily) is the trade-off for a minimal wire format. The future `stack-encrypt`'s native binding wouldn't have this asymmetry. + +## Error propagation + +If the JS `getToken` throws or rejects, the adapter surfaces an `AuthError`. Recommendation: + +- JS sync throw or promise reject → `AuthError::Server(message_from_js_error)`. +- Adapter-side dropouts (`oneshot::Receiver::recv` returning `Err`) → `AuthError::Server("JS strategy dropped before responding")`. + +`cipherstash-client` treats this the same way it treats any other auth failure (no oracle leakage; standard surfaced via the existing error path). + +## Lifecycle and zeroize + +- **Strategy lifetime**: the JS strategy is held for the lifetime of the protect-ffi client. `JsAuthStrategy` holds a `Root` (Neon's persistent handle) to keep the JS callable alive. The `Drop` impl releases the root via `self.channel.send(...)`. +- **Token zeroize**: the JWT crosses the FFI boundary as a JS `String`, which has no `ZeroizeOnDrop` protection while in JS-land — the same caveat already established in `TokenResultPayload`'s docstring (`packages/stack-auth/wasm/src/lib.rs:42-46`). Once `ServiceToken::new(SecretToken::new(jwt))` lands on the Rust side, normal `ZeroizeOnDrop` protections resume for the rest of the request's lifetime. +- **Concurrent `get_token`**: `cipherstash-client` may issue concurrent ZeroKMS requests, each of which calls `(&credentials).get_token().await` — `JsAuthStrategy` must be safe to call from multiple async tasks. Neon's `Channel::send` is `Send + Sync`, so this is fine; the JS side runs each call serially on the libuv main thread, but the Rust side awaits independent oneshots so multiple in-flight `get_token` calls don't block each other. + +## Shelf life + +This is bridge scaffolding. Once `stack-encrypt` lands with its own napi/wasm bindings that accept a `stack_auth::AuthStrategy` natively (no JS-callback round-trip per ZeroKMS request), `protect-ffi`'s `JsAuthStrategy` adapter can be retired and consumers migrate to `@cipherstash/protect` (or whatever the published package becomes). + +[`AuthStrategyFn`](../packages/stack-auth/src/auth_strategy_fn.rs) itself stays useful past that retirement — any foreign Rust consumer that wants to bring a non-`stack-auth`-native strategy to `cipherstash-client` (third-party integrations, test fixtures, future sidecars) uses the same pattern. + +## Why no changes to cipherstash-suite production code + +`cipherstash_client::ZeroKMSBuilder::new` already accepts any `C` where `for<'a> &'a C: stack_auth::AuthStrategy` (see `src/zerokms/builder.rs:108-118` in the `cipherstash-client` crate, in cipherstash-suite). `JsAuthStrategy` satisfies that bound by virtue of `impl AuthStrategy for &JsAuthStrategy`. No generic refactor, no `dyn AuthStrategy`, no trait additions. + +The only thing this repo ships in support of this RFC is: + +- `stack_auth::AuthStrategyFn` — public helper on the acquisition layer, sibling of `stack_auth::TokenStoreFn` on the persistence layer. Saves protect-ffi (and any future foreign consumer) ~20 lines of trait-impl boilerplate. +- A doctest in `cipherstash-client::zerokms::builder` (compile-only) showing the `AuthStrategyFn` → `ZeroKMSBuilder::new` composition. Catches regressions if anyone tightens the builder's trait bound. + +## Out of scope + +- `protect-ffi` PR itself — separate repo. +- `stack-encrypt` design — eventual target state, separate work item. +- OAuth strategy via `protect-ffi` — same callback shape would work, but the JS-side OAuth bindings aren't shipped yet (deferred under CIP-3084's broader follow-ups). +- Encryption-at-rest decorator for the strategy — separate sub-issue [CIP-3112](https://linear.app/cipherstash/issue/CIP-3112); doesn't affect this handover wiring. diff --git a/docs/fuzzing.md b/docs/fuzzing.md new file mode 100644 index 000000000..448550122 --- /dev/null +++ b/docs/fuzzing.md @@ -0,0 +1,154 @@ +# Fuzzing (cargo-fuzz / libFuzzer) + +How the repo fuzzes its public, untrusted-input parsers, how to run a +target locally, and how to add a new one. The short-form recipe lives in +[`AGENTS.md`](../AGENTS.md); this is the longer explanation. + +For background on cargo-fuzz itself — sanitizers, corpus management, +structure-aware fuzzing with `arbitrary`, triaging crashes — use the +[`cargo-fuzz` skill](https://github.com/trailofbits/skills) (Trail of +Bits). We deliberately do **not** maintain our own fuzzing skill; that +skill is the reference, and this doc only covers what's repo-specific. + +## What we fuzz and why + +We fuzz the parsers that turn **untrusted caller-supplied input** — strings +from a caller, or bytes read back from storage — into domain types. The invariant under test is always the same: parsing +arbitrary input must **never panic** — malformed input must return an +`Err`, not crash the process. + +Current targets: + +| mise task | crate | target binary | parses | +|----------------------|---------------|-----------------------|-----------------------------------------------| +| `fuzz:access-key` | `stack-auth` | `access_key_parse` | `AccessKey` (`CSAK.`) | +| `fuzz:jwt-decode` | `stack-auth` | `jwt_decode` | JWT claims (`Token::fuzz_decode_claims`) | +| `fuzz:client-key` | `stack-kms` | `client_key_encoded` | `ClientKey::from_encoded_v1` (hex or base64) | +| `fuzz:sealed-value` | `stack-encrypt` | `sealed_value_decode` | `SealedValue::from_bytes` (frozen v1 leaf); accepted input must re-encode to itself | +| `fuzz:term-decode` | `stack-encrypt` | `term_decode` | the SEM term decoders (`EqualityTerm`, `MatchTerm`, `OreTerm`, `OpeTerm` `from_bytes`) | +| `fuzz:check-record` | `stack-encrypt` | `check_record` | `dynamic::record::check_record` over an `Arbitrary`-derived plan and tree, against a model of the record rules (structure-aware) | + +Each target is a few lines — `libfuzzer-sys` hands a `&str` (or `&[u8]` +for the byte decoders) to the parser via the `arbitrary` crate: + +```rust +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|s: &str| { + let _ = s.parse::(); +}); +``` + +When the code under test isn't a public `FromStr` — e.g. the JWT claims +decoder, whose entry points are `pub(crate)` — we don't widen the real +API. Instead the crate exposes a thin **fuzz-only** entry point behind a +`fuzz` Cargo feature (`Token::fuzz_decode_claims`), and the fuzz crate +enables that feature on its dependency (`features = ["fuzz"]` in +`fuzz/Cargo.toml`). Using a Cargo *feature* rather than `#[cfg(fuzzing)]` +keeps `fuzz` a known cfg, so it never trips the `unexpected_cfgs` lint +under CI's `-D warnings`. + +## Layout + +Each fuzzed crate has a `fuzz/` subdirectory that is a **detached +workspace** — its `Cargo.toml` ends with an empty `[workspace]` table so +the `libfuzzer-sys` dependency and the nightly-only build never touch the +main monorepo workspace, and it is **not** a member of the root +`Cargo.toml`: + +``` +packages/stack-auth/fuzz/ + Cargo.toml # detached workspace, cargo-fuzz = true + Cargo.lock # tracked, so `--locked` and Dependabot see it + fuzz_targets/*.rs # one file per target binary + corpus//* # committed seed inputs (valid examples) +packages/stack-kms/fuzz/ +packages/stack-encrypt/fuzz/ + … +``` + +The committed `corpus//` seeds are valid examples of each format. +They give the fuzzer (and the CI regression replay) a starting point, and +the scheduled campaign grows the corpus from there. + +## Running locally + +cargo-fuzz needs the **nightly** toolchain and the `cargo-fuzz` binary; +`mise` provides the latter (`cargo:cargo-fuzz` in `mise.test.toml`, +which the `fuzz:*` tasks load with `mise x --env test`). Install +nightly once with `rustup toolchain install nightly`. + +Run a target via its `mise` task (60s by default): + +```bash +mise run fuzz:access-key +``` + +Override the duration by appending another libFuzzer flag — the last +value of a repeated flag wins: + +```bash +mise run fuzz:access-key -- -max_total_time=300 +``` + +Replay only the committed seed corpus without fuzzing (what CI's +regression job does): + +```bash +mise run fuzz:access-key -- -runs=0 +``` + +The tasks pin `--sanitizer none` (these parsers are pure safe Rust, so +ASan buys nothing and roughly doubles throughput) and +`--target $(rustc … host)` (the cargo-fuzz binary can be an x86_64 build +under Rosetta on Apple Silicon, which otherwise misdetects the target and +fails to find `std`). + +A crash drops a reproducer into `fuzz/artifacts//`; re-run that +single input with `cargo +nightly fuzz run `. + +## CI (`.github/workflows/fuzz.yml`) + +The workflow arrives with the CI port of the stack-* crates. Two jobs with deliberately different roles: + +- **fuzz-regression** (`pull_request`, **blocking**): builds every + harness — which catches harness/API drift, e.g. a changed `FromStr` + signature — and replays the committed seed corpus with `-runs=0`. This + is deterministic (no fuzzing), so it's safe to gate PRs: it fails only + if a harness stops compiling or a committed corpus input crashes. + +- **fuzz-campaign** (`schedule` nightly + `workflow_dispatch`, + **non-blocking**): the actual time-boxed bug-finding run. A timed fuzz + run is nondeterministic, so it must not gate PRs. The corpus is + persisted across runs via `actions/cache` (write-once key + prefix + `restore-keys`) so coverage compounds, minimized with `cargo fuzz cmin` + to stay small, and any crash reproducer is uploaded as an artifact. + +The `pull_request` trigger is path-filtered to `packages/stack-auth/**`, +`packages/stack-kms/**`, `packages/stack-encrypt/**`, and the workflow file, +with `!**.md` / `!**.example` excludes last so docs-only changes are +skipped. + +## Adding a new target + +1. Pick the crate whose parser you're fuzzing and add a target file under + `packages//fuzz/fuzz_targets/.rs` (copy an existing one). +2. Register it as a `[[bin]]` in that crate's `fuzz/Cargo.toml`. +3. Commit at least one valid seed under `fuzz/corpus//`. +4. Add a `fuzz:` task in the crate's `tasks.toml` mirroring the + existing ones (nightly, `--sanitizer none`, host `--target`). +5. Add the target to **both** matrices in `fuzz.yml` — the regression + `include` (task + slug) and the campaign `include` (task, slug, dir, + target). + +If the entry point is `pub(crate)`, add a `fuzz`-feature-gated shim +rather than widening the public API (see `Token::fuzz_decode_claims` +above), and enable that feature on the dependency in `fuzz/Cargo.toml`. + +If the parser can only build for a non-native target, note it as future +work rather than wiring a native target — cargo doesn't gate +`[target.'cfg(…)']` deps on `--cfg fuzzing`, so it needs a wasm-target +build. `fuzz:jwt-decode` covers the native `jsonwebtoken` decode path; +the hand-rolled wasm base64/JSON decoder (whose `base64` dep is +wasm32-only) is not yet fuzzed for this reason. diff --git a/docs/npm-releases.md b/docs/npm-releases.md new file mode 100644 index 000000000..1c7fd5342 --- /dev/null +++ b/docs/npm-releases.md @@ -0,0 +1,189 @@ +# npm releases (changesets) + +> Adopts [changesets](https://github.com/changesets/changesets) for the `@cipherstash` +> npm products while release-plz keeps owning the Rust crates. Tracking issue: +> [CIP-3278](https://linear.app/cipherstash/issue/CIP-3278). Covers the full +> loop: **versioning** (the Version Packages PR) **and publishing** (the native +> matrix workflows, auto-triggered when a version bump lands on `main`). + +## Why + +Releasing the `stack-auth` **crate** (release-plz → crates.io) is independent of +publishing the `@cipherstash/auth` **npm package** that binds to it. Hand-editing +the npm `package.json` version caused real drift (an unintended `0.39.0` publish, a +`0.40.0`/`0.38.0` mismatch, a reconstructed changelog — see PR #2057). + +We do **not** want lock-step version numbers. We want a reliable, low-ceremony +npm release workflow — the same [changesets](https://github.com/changesets/changesets) +flow used in `cipherstash/stack`. + +## The two-tool seam + +release-plz and changesets coexist because they partition by **language + +registry** and never read or write each other's files: + +| | release-plz | changesets | +|---|---|---| +| Reads/writes | `Cargo.toml`, `Cargo.lock`, Rust `CHANGELOG.md` (via `cliff.toml`) | `package.json`, npm `CHANGELOG.md`, `.changeset/*.md` | +| Publishes to | crates.io | npm | +| Tags | `stack-auth-v…`, `cipherstash-client-v…` | `@cipherstash/auth@…` / "Version Packages" PR | + +## How a release works + +1. **Contributor**: change a JS/TS package, then `npx changeset` (from repo root, + after a root `npm install`) → pick package(s) + bump level + summary → commit + the generated `.changeset/*.md` with your code. No hand-edited versions or + changelogs. +2. **On merge to `main`**: `.github/workflows/release-npm.yml` runs the + `changesets/action`, which opens/updates a **"Version Packages" PR** applying + the accumulated bumps to `package.json` + `CHANGELOG.md` (and re-syncs the root + `package-lock.json` via the `version-packages` script). +3. **Cut the release**: merge the Version Packages PR. +4. **Publish (automated)**: merging step 3 bumps each product's + `node/package.json` on `main`. Each publish workflow + (`publish-auth-npm.yml`, `publish-profile-npm.yml`) is **path-filtered on its + own `node/package.json`**, so the bump triggers it; a `preflight` job then + publishes **only if that version is not already on npm** (so any other push + touching `package.json` is a no-op). It builds the napi matrix (+ wasm for + auth) and publishes, reading the version changesets just wrote. The release + workflow itself deliberately does **not** publish — napi packages need the + matrix build the bespoke workflows own. + +The seam: `release-npm.yml` versions; the `publish-*-npm.yml` matrix workflows +publish. The **Version Packages PR merge is the single human gate** — there is no +separate publish approval, matching `cipherstash/stack` (whose pure-JS packages +let `changesets/action` publish inline; ours can't because of the native matrix). + +## Scope + +- **Managed**: `@cipherstash/auth` (`packages/stack-auth/node`), + `@cipherstash/profile` (`packages/stack-profile/node`). +- **Not managed** (build artifacts / private): the `npm/*` platform sub-packages + (`@cipherstash/auth-darwin-x64`, …) — stamped from the main version at publish + time (`publish-auth-npm.yml:198-221`); the `0.0.0-pre` wasm package; and + non-product packages (`load-tests`, health-checks). Excluded purely by the + `workspaces` globs in the root `package.json` — the `.changeset/config.json` + `ignore` list is empty (see "Validation log" for why it was dropped). + +## What this validates + +- `changeset status` discovers **exactly** `@cipherstash/auth` and + `@cipherstash/profile`, ignoring the platform sub-packages and wasm. (See + "Validation log" below.) +- The root manifest is `private: true` and lists only the two products, so it has + no effect on crates / release-plz. + +## npm workspaces decision (resolved) + +The root `package.json` introduces **npm workspaces** where there were none, so +the effect on the existing `npm install` steps was checked empirically: + +- **The napi build is unaffected.** The publish pipeline's only Node dependency + is the `napi` binary (`@napi-rs/cli`); the products have **zero runtime + `dependencies`**. After a workspace install, `npx napi` still resolves from + `packages/stack-auth/node/node_modules/.bin`, so `napi build` / `napi + artifacts` work exactly as before — **no change to `publish-auth-npm.yml` is + needed**. +- **Only the two products are in the workspace.** The other nested JS packages + (`load-tests`, `health-checks/typescript`, `usage-metrics-tracker`, + `cts-web`) are **not** matched by the `workspaces` globs — verified `npm + prefix` from `load-tests/` returns its own dir and `npm ci` there still + resolves against its own lockfile, so `test-load-tests.yml` (the only `npm ci` + user) is untouched. +- **One lockfile is the source of truth.** A root `package-lock.json` governs + the workspace; the per-package lockfiles under `stack-auth/node` and + `stack-profile/node` are removed (npm ignores them in workspace mode). The + build uses `npm install` (not `npm ci`), so it adapts platform-specific + optional deps per runner. + +The release workflow installs only the changesets CLI +(`npm ci --no-workspaces --ignore-scripts`), so the versioning job never +touches the napi toolchain. + +### Operational notes + +- **"Allow GitHub Actions to create and approve pull requests" must be on.** + Without it, `release-npm.yml` runs green but silently opens no Version Packages + PR (a 403 the workflow can't self-guard). Repo → Settings → Actions → General. +- **First `@cipherstash/profile` publish.** Profile and its `@cipherstash/profile-*` + platform sub-packages are not yet on npm; the first Version Packages merge that + bumps profile creates them. Auth is already published, so its guard skips until + the next bump. +- **CHANGELOG handover.** The first `changeset version` will prepend a + changesets-formatted section above the existing hand-written history (same + `## x.y.z` shape), so no migration is required; merging this PR with no + pending `.changeset/*.md` is a no-op. + +### Still deferred (follow-ups, not blocking this PR) + +- **npm provenance / OIDC trusted publishing.** The matrix workflows authenticate + with `NPM_TOKEN`. Moving to OIDC trusted publishing (as `cipherstash/stack` + does) would add provenance attestations; it needs per-package npm config and a + GitHub-hosted publish runner. +- **Optional publish approval gate.** Publishing is gated only by the Version + Packages PR review/merge. If a stricter gate is wanted, add a GitHub + Environment (e.g. `npm-publish`) with required reviewers to the `publish` jobs. + +## Extending to Python / C# / Ruby (and future stack-encrypt, stack-zerokms) + +Changesets is npm-only — it does not generalize to PyPI / NuGet / RubyGems. The +principle that **does** generalize: + +> One core crate per product (release-plz → crates.io). N language bindings, each +> its own artifact in its own registry, versioned independently, joined by +> **convention, not by a shared version number**. + +| Binding | Registry | Tool options | +|---|---|---| +| Rust core | crates.io | release-plz (in place) | +| JS/TS + wasm | npm | changesets (this doc) | +| Python (PyO3/uniffi) | PyPI | release-please / python-semantic-release / maturin + bump | +| C# (uniffi) | NuGet | release-please (.NET) / MinVer / Nerdbank.GitVersioning | +| Ruby (magnus/uniffi) | RubyGems | release-please (Ruby) / rake release | + +Two cross-cutting standards keep N tools manageable as products × languages grow: + +1. **Provenance over lock-step.** Every binding artifact records the exact core + crate **version + git SHA** it was built from (a metadata field in + `package.json` / `pyproject.toml` / `.csproj` / `.gemspec`, ideally also a + runtime constant). That is the real "in sync": not equal numbers, but "this + published binding provably wraps core X.Y.Z @ sha". +2. **A uniform CI shape.** A reusable "binding release" workflow parameterized by + `(product, language, registry)` — matrix build → stamp version + provenance → + publish — instead of copy-pasting `publish-*-npm.yml` per product/language. + +### Decision deferred to a second ticket + +Two "intent" models would coexist: release-plz/cliff derive changelogs from +**conventional commits**; changesets uses **explicit `.changeset/*.md` files**. +Fine for two tools; confusing across five ecosystems. Options: + +- **(A)** Per-ecosystem idiomatic tools + the provenance standard (recommended + near-term). +- **(B)** Converge all bindings on `release-please` (multi-language), keep + release-plz for crates, drop changesets. +- **(C)** Changesets as a polyglot intent layer with custom appliers for non-npm + manifests. + +Recommend **(A)** now; revisit **(B)** deliberately when a second binding +language is greenlit. Bake in provenance + the reusable workflow from the first +non-Rust binding regardless of which intent model wins. + +## Validation log + +Run during development with two throwaway changesets (since removed): + +``` +$ npx @changesets/cli status +info Packages to be bumped at patch: +- @cipherstash/auth +info Packages to be bumped at minor: +- @cipherstash/profile +info NO packages to be bumped at major +``` + +- Both products are discovered and bump independently (auth→patch, profile→minor). +- The `npm/*` platform sub-packages and the wasm package are **not** in the + project at all: an early `ignore: ["@cipherstash/auth-*", …]` config was + *rejected* with "not found in the project", which confirms changesets never + enumerates them. The `ignore` list was therefore dropped as unnecessary. diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md new file mode 100644 index 000000000..fa00c797e --- /dev/null +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -0,0 +1,630 @@ +# stack-encrypt Go bindings + +> **Plan, not specification.** This document is an indicative sketch of the +> steps required, written before the work was done. It is not kept in step +> with the implementation and must not be used as a formal specification or +> as a reference for reviewing what was actually built: the code, its +> rustdoc and the tests are the source of truth. Where the two disagree, the +> code wins and this document is simply out of date. + +**Status:** in progress — Phases 0 through 3 are open as stacked draft PRs on #2156 +**Date:** 2026-08-27 +**Builds on:** #2099 (WASI/wazero beachhead), #2156 (`#[derive(EncryptFrom, DecryptInto)]`), vitaminc `bindings/go` (`vcvalue` + `vcencrypt`) + +## Goal + +Prove that `stack-encrypt` as it stands after #2156 — ZeroKMS-backed AEAD over +structured values, plus SEM index terms, plus batched records — can be driven +from Go with `CGO_ENABLED=0`, one embedded `.wasm`, and no per-platform build +matrix. The proof is a Go program that, against a real ZeroKMS: + +1. encrypts a slice of records (ciphertext + equality + ORE term per field) + in **one** `generate-data-key` call, +2. decrypts them back in one `retrieve-data-key` call, +3. builds a query probe term that equals the stored term (under the local + HMAC backend that derivation needs no ZeroKMS call; under a backend that + derives terms at the server — ZeroKMS v2 — the probe settles through the + same batched call the record path uses), and +4. round-trips ciphertexts and terms with the native Rust example + (`encrypted_record.rs`) in both directions. + +Everything that is not needed for that proof is a follow-up. + +## Terminology + +"Transport" is used with two opposite meanings across vitaminc and #2099. This +plan fixes the vocabulary and the rename is part of the work: + +| term | means | bytes go | +|---|---|---| +| **storage format** / **sealed leaf** | `vcvalue.Sealed`, `stackencrypt.Sealed`, the `[tag] ++ payload` inside the envelope | into a database column; frozen, versioned | +| **FFI codec** (was "transport codec") | marshalling a value or ciphertext *tree* across wasm linear memory in one copy; `FfiValue`, `CT_*` framing, `Encoder`/`Encryptable` | host ↔ guest, inside the process; throwaway, never persisted | +| **transport** | HTTP to ZeroKMS: `cipherstash_transport::transport_send`, the future `stack-transport` crate | out of the process | + +Renames implied: `vitaminc_aead_value::transport` → `::ffi`; the Go codec +module is `vcffi`, not "wire"/"transport"; READMEs say "FFI-only, not a +storage format". "Interop" is avoided as too broad (the reflection encoder is +interop too, but it is API, not a codec). Also: "cipher handle" (not "session +handle"), and the "Status: spike" labels come off the vitaminc `bindings/go` +READMEs — the code was reviewed and tested past that point, and `stackencrypt` +cannot build on something still labelled a spike. + +## What is already done, and where + +The work splits cleanly along the crate boundary, and most of it exists. + +| Concern | Owner | State | +|---|---|---| +| Value model (`Plain`, `Sealed*`, `Object`), `Encryptable`/reflection encode, decode natives | vitaminc `bindings/go/vcvalue` + `vcencrypt` | done — reviewed and tested; the READMEs still self-label "spike", which is stale | +| FFI codec (`FfiValue` tree ↔ bytes, `CipherText` ↔ bytes) — today named "transport" in vitaminc | vitaminc `packages/aead-value/src/transport.rs` (Rust); unexported Go copy inside `vcencrypt` | done, but the Go codec is private to `vcencrypt` | +| Replaying a whole value tree through a `Cipher` in one guest call; `Encrypt for FfiValue` / `Decrypt for FfiValue` | vitaminc `aead-value` | done, generic over any `Cipher` — including `&StackCipher` | +| Guest ABI conventions: `vc_alloc`/`vc_dealloc` with a guest-owned buffer registry + zeroize, packed-`u64` results, status codes, cipher handles, hostile-input validation | vitaminc `vcencrypt/guest/src/abi.rs` | done, but lives inside the `vcencrypt` guest crate | +| Go client shell: wazero runtime, process-wide compilation cache, mutex-serialised instance, sentinel errors, `driver.Valuer`/`sql.Scanner` on leaves | vitaminc `vcencrypt/client.go` | done | +| ZeroKMS transport over a single host import (`cipherstash_transport::transport_send`), `WasiHostConnection: ZeroKMSConnection`, `block_on` inside the guest | suite #2099 (`packages/stack-encrypt/guest`, unmerged) | proven end to end, but written against `cipherstash-client::zerokms::vitur_client` | +| Go host side of that import (`bridge.go`: `net/http` transport, bounds-checked memory ABI, `-check` import-surface gate) | suite #2099 | proven | +| Integration harness: boot `zerokms-server` trusting the mock auth server, mint a token, seed a client, `go test` | suite #2099 (`test:integration:wasi-spike`) | proven | +| `wasm:wasi-check` gate: HTTP-free core compiles for `wasm32-wasip1` with no `wasm-bindgen`/`web-sys`/`js-sys` | suite #2099 | done for `zerokms-protocol`, `cipherstash-core`, `recipher`, `cts-common`, `cllw-ore` | +| `StackCipher` (`Cipher` impl building a pending tree, `seal` batching, `StackDecipher: Decipher`), SEM terms, `EncryptFrom`/`Pending`, derive | suite `stack-encrypt` (#2147, #2156) | done | +| `ZeroKMSConnection` seam in the new client | suite `stack-kms/src/connection.rs` | trait exists; `StackKms` does not use it (see blockers) | + +So the stack-encrypt side of the binding is, as expected, the **cipher/KMS +side**: getting `stack-kms` + `stack-auth` to build for WASI without HTTP, +exposing a `StackCipher` as a cipher handle across the ABI, and defining the +cross-language byte formats stack-encrypt owns (the sealed leaf and the +terms). The value model and the FFI plumbing are vitaminc's and are reused. + +## Blockers found (measured, not predicted) + +Ran `cargo check --target wasm32-wasip1 -p stack-encrypt --no-default-features` +on this branch (`claude/stack-encrypt-derive`): + +``` +error: Only features sync,macros,io-util,rt,time are supported on wasm. + --> tokio-1.49.0/src/lib.rs:481:1 +error: failed to run custom build command for `aws-lc-sys v0.44.0` +``` + +Both come from **`reqwest 0.13.4`** and only from it — via `stack-auth` and +`stack-kms` (the only two crates in the graph that depend on it). On +`wasm32-wasip1` reqwest 0.13.4 selects its *native* backend, dragging in +hyper/tokio-full/hickory/rustls/aws-lc-sys. The good news: **no +`wasm-bindgen`/`web-sys`/`js-sys` anywhere in the wasip1 tree**, so the +JS-host chain #2099 fought is gone; what remains is exactly the follow-up +#2099 named — reqwest has to be out of the WASI build *by construction*, not +by dead-code elimination. + +To be clear about what `aws-lc-sys` is doing there: it is rustls's default +crypto provider for TLS inside reqwest's native backend, not the AEAD. +`vitaminc-encrypt` already selects its pure-Rust `aes-gcm` backend on +`cfg(target_arch = "wasm32")`, which covers wasip1, so the guest's own +cryptography builds today; only the network stack is missing. + +Structural blockers on top of that: + +1. **`StackKms` hard-codes `Client`** + (`packages/stack-kms/src/client.rs:386`). The low-level `Client` is already generic; the high-level wrapper is not, so + there is no way to inject `WasiHostConnection` today. +2. **`stack-auth` uses reqwest unconditionally** in `device_client.rs`, + `access_key_refresher.rs`, `oidc_refresher.rs`, `token.rs`, and + `error.rs` (`RequestError(pub reqwest::Error)`, `From + for AuthError`). `AuthStrategyFn`, `ServiceToken`, `AuthStrategy` + and the error enum are HTTP-free and are all a first guest needs + (`StaticTokenStrategy` is test-utils-only and stays that way). +3. **`cfg(target_arch = "wasm32")` currently means "JS host"** in + `stack-auth`/`stack-kms` (fetch semantics, no timeouts, `MaybeSend` + drops `Send`). WASI under wazero is single-threaded too, so the `Send` + relaxations are fine, but comments and a few branches (`AutoStrategy`'s + wasm arm) assume no filesystem and a JS credential. Nothing breaks the + proof; it needs tidying before anything ships. +4. **`SealedValue` has no frozen byte layout.** It derives serde and offers + `into_parts()`, but the FFI codec is generic over `Leaf: + AsRef<[u8]> + From>` and a Go database column needs *one* byte + string. stack-encrypt states this leaf is "the only byte-format commitment + the crate makes"; the commitment has to be written down. +5. **Terms have no cross-language byte encoding** in stack-encrypt. + `EqualityTerm` is 32 bytes; `MatchTerm` is `Vec` positions; + `OreTerm`/`OpeTerm` wrap `cllw_ore::…::Output`, whose serialisation + lives in the EQL layer today. + +## Architecture + +Same shape as #2099 and the vitaminc Go bindings, applied to `stack-encrypt`: + +``` +Go application + └─ github.com/cipherstash/…/stackencrypt (Go, CGO_ENABLED=0) + ├─ imports vcvalue (value model) + the FFI codec + ├─ embeds stack_encrypt_guest.wasm + ├─ host import cipherstash_transport::transport_send → net/http → ZeroKMS + └─ host import cipherstash_transport::token_get → token source (phase 1: static) + │ + ▼ wazero (wasm32-wasip1) + stack-encrypt guest (Rust cdylib) + ├─ StackCipher> (one per instance) + ├─ opts.keyset → cipher.keyset(selector) → KeysetCipher (per call; a cold name/id is one load) + ├─ FfiValue.encrypt_with_aad(&keyset_cipher, aad) → pending → seal (block_on; one generate_keys per 500 leaves) + ├─ record plan → per-field EncryptFrom pendings → Pending::all → generate_keys, chunked the same way + └─ term(value, context, kind) → keyset_cipher.term: the backend derives it (local PRF/ORE today, no I/O; ZeroKMS v2 derives server-side) +``` + +Control stays in Rust: request assembly, key derivation, batching, AAD/PRF +context binding all run unmodified inside the guest. What crosses the boundary +per call is a value tree in, a ciphertext/record tree out, and — inside the +call — the same bytes that would cross TLS anyway. The client key enters guest +memory once at `init`; derived data keys and the index key never leave. + +## Phases + +### Phase 0 — salvage #2099 onto `stack-kms` + +**Landed** as the first stacked PR: the gate, the CI workflow, Layer 6, and +this document. `WasiHostConnection`, the `bridge.go` host function and the +integration harness are ported in Phase 3 with the guest they serve; #2099 +is left open with a pointer here for its author to close. + +#2099 targets `cipherstash-client`, which is being replaced by `stack-kms` +(no parity fixes go into the old crate). Rebase the reusable pieces rather +than the branch: + +- `wasm:wasi-check` mise task — keep, extend to `stack-kms`, `stack-auth`, + `stack-encrypt`, and add `reqwest|tokio|aws-lc-sys` to the forbidden-tree + grep (the wasip1 failure mode is now native-backend, not JS-backend). +- `WasiHostConnection` — port from `cipherstash_client::zerokms::vitur_client` + to `stack_kms::ZeroKMSConnection` (the trait shape is identical; the port + is mechanical). +- `bridge.go` `transportSend` host function and `checkImports` — keep as the + host side; drop the msgpack request/response types (superseded by the + vitaminc FFI codec). +- `test:integration:wasi-spike` harness (zerokms-server on a dedicated port, + mock auth issuer override, mint token, seed client) — keep, rename. +- `wasm-analysis.md` Layer 6 — rewritten for the stack-kms target and the + measured wasip1 blocker. +- Close #2099 with a pointer here; nothing from it merges as-is. + +### Phase 1 — `stack-kms` and `stack-auth` build for WASI without HTTP + +**Landed (stacked PR on Phase 0).** What shipped, against the plan below: a +default-on `http` feature in all three crates (`stack-encrypt/http` → +`stack-kms/http` → `stack-auth/http` → `dep:reqwest`); `StackKms` +with `StackKms::connect(opts, credentials, client_key)` as the +transport-injecting constructor; `ZeroKMSConnection` grew +`ensure_base_url` / `has_base_url` so endpoint discovery from the token's +`services` claim works over any connection; `StackCipher::builder()` moved +to `impl StackCipher` so it resolves without `http`; +`wasm:wasi-check` gates all eight crates. A unit test drives `StackKms` +end to end over the in-memory `TestConnection`. Verified: +`cargo check --target wasm32-wasip1 -p stack-encrypt --no-default-features` +passes with no `reqwest`/`hyper`/`aws-lc-sys` in the tree. + +The plan as written before the work: + +**stack-kms** + +- `StackKms`; `StackKmsBuilder::with_connection(conn)` + (or a `connect_with` constructor) so the guest can pass + `WasiHostConnection`. `get_token`'s `ensure_base_url` moves behind a small + trait method on the connection (`HttpConnection` needs it; the host + connection ignores it — the Go host owns the URL). +- `HttpConnection` + `HttpConnectionOpts` behind `#[cfg(not(target_os = + "wasi"))]` (or a default-on `http` feature — pick one and use the same in + stack-auth). The `ZeroKMSConnection` trait, `Client`, key derivation, + `DataKeySource`/`IndexKeySource` stay unconditional. + +**stack-auth** + +- Default-on `http` feature gating everything that touches reqwest: + `device_client`, `access_key_refresher`, `oidc_refresher`, the + `AutoStrategy`/`AccessKeyStrategy`/`DeviceSession`/`OidcFederation` + strategies, `RequestError`, `From`. Left unconditional: + `AuthStrategy`, `AuthStrategyBounds`, `AuthStrategyFn`, `ServiceToken`, + `Token`, `AuthError` (minus the `Request` variant's payload), `SecretToken`. + `AuthStrategyFn` is the supported production path for a no-`http` consumer + that sources tokens externally; `StaticTokenStrategy` stays behind + `cfg(any(test, feature = "test-utils"))` — it is a test double, not part of + the no-`http` production surface. +- The guest's `HostTokenStrategy` (below) implements `AuthStrategy` over a + host import, so nothing else is required for the proof. + +**stack-encrypt** + +- `StackCipher::new()` / `StackCipherBuilder` are native-only + (they use `AutoStrategy` + profile); gate them the same way. The generic + `StackCipherBuilder::kms(k).keyset(..).init()` path is what the guest + uses and needs no change. +- Gate: `wasm:wasi-check` now passes for all three crates. + +### Phase 2 — frozen byte formats stack-encrypt owns + +**Landed (stacked PR on Phase 1).** What shipped, against the plan below: +`SealedValue::to_bytes`/`from_bytes` with the layout +`version(1) ‖ iv(16) ‖ tag_len(u16 LE) ‖ tag ‖ local_ciphertext`, the +version byte bound into the leaf AAD via a new labelled derivation +(`PAE("stack-encrypt/leaf", version, derived_aad, tag)` — replacing the +unlabelled `(aad, tag)` tuple, with the derivation bytes pinned by a unit +test; **breaking**: leaves sealed under the phase-1 AAD carry no version byte, +so they cannot be opened and fail with a plain AEAD error rather than an +`UnknownVersion` — acceptable because the crate is `publish = false` and only +dev-persisted data exists); term encodings frozen as raw-bytes (equality: the 32 PRF bytes; +ORE/OPE: the raw CLLW ciphertext, byte-identical to what EQL hex-encodes +into `hm`/`oc`/`op`; match: LE `u16` positions — EQL sends `bf` as a JSON +integer array, so the byte-string form is stack-encrypt's own *transport* +encoding across the wasm/FFI boundary, not a storage commitment — what is +stored and queried is the position list). The surface per type: +`to_bytes` and a fallible `TryFrom<&[u8]>` on all four; `from_bytes` on all +four (infallible over `[u8; 32]` for `EqualityTerm`, fallible over a slice +for the rest); `as_bytes` only where the term is a contiguous buffer +(`EqualityTerm`, `OreTerm`, `OpeTerm`) — a `MatchTerm` is canonically a +position list, so it has none, and its decoders range-check every position +against the `MatchConfig`'s filter size. Decode failures are the structured, +`PartialEq` `TermBytesError`. Also: length-validating +`TryFrom<&[u8]>` added to cllw-ore's variable-width ciphertext types; and +golden vectors in `tests/frozen_bytes.rs` for the Go decoder to test +against. + +The plan as written before the work — these are storage commitments, so they +get decided and documented before the guest is written, independently of Go: + +- **`SealedValue` leaf**: `to_bytes()` / `from_bytes()` with a canonical + layout, e.g. `version(1) ‖ iv ‖ u16 tag_len ‖ tag ‖ local_ciphertext` + (`local_ciphertext` is already `version ‖ nonce ‖ ct ‖ gcm_tag`). Bind the + outer version byte into the leaf AAD the way vitaminc binds its version + byte, so a relabelled leaf fails authentication rather than parsing. Rust + `impl AsRef<[u8]>`-style access for the codec's `Leaf` bounds comes for + free. +- **Terms**: `EqualityTerm` — the 32 bytes as-is. `MatchTerm` — `u16` + little-endian positions. `OreTerm`/`OpeTerm` — adopt the EQL encoding of + the underlying `cllw-ore` output rather than inventing one; Postgres is + the real consumer and Go rows must be comparable with rows the Rust/EQL + path wrote. Needs a look at what `eql-bindings` emits today. +- Add these as `#[cfg(test)]` golden vectors in `stack-encrypt` (Rust + encodes → fixed hex) so the Go decoder tests against the same bytes. + +### Phase 3 — the guest + +**Landed (stacked PR on Phase 2).** What shipped, against the plan below: +the crate at the planned location (detached workspace), exporting +`se_alloc`/`se_dealloc`, `se_cipher_init`/`se_cipher_free`, +`se_encrypt`/`se_decrypt` (+`_element`), `se_encrypt_record`/ +`se_decrypt_record`, and `se_term`, under the vitaminc guest's ABI +conventions (buffer registry with zeroizing dealloc, packed-`u64` results, +hostile-input validation; status codes 1–4 byte-identical to vitaminc's, +5–11 added for the ZeroKMS request outcomes and term failures). +`WasiHostConnection` implements `stack_kms::ZeroKMSConnection` over the +generalised `transport_send(method, url, headers, body)` import (headers as +`name: value` lines), with the endpoint pinned from the config or +discovered from the token's `services` claim via `ensure_base_url`; +`HostTokenStrategy` fetches the bearer token per request over `token_get`. +Records deviate from the sketch in two small ways: there is no separate +`aad` argument (each plan field's `context` *is* the AAD, as in the target +layer) and the result rides the ciphertext codec — per field a map of +output keys (`"c"`, `"eq"`, `"match"`, `"ore"`, `"ope"`) whose term nodes +are passthrough bytes. Batching all rows into one `generate_keys` goes +through a new public `PendingStackCipherText::into_pending` in +stack-encrypt (decoded `FfiValue`s are not `Clone`, so the `EncryptFrom` +path was not usable). Extracting the shared ABI into a `vitaminc-wasi-abi` +crate is out of this repo's reach and stays a vitaminc follow-up — the +registry/session modules are copies with a pointer back. The `bridge.go` +host function and the integration harness land with their consumer, the Go +module (Phases 4–5). Verified: native tests over `FakeDataKeySource` +(round trips, term-byte equality with the native `sem` calls, a counting +key source proving one ZeroKMS call per record batch), and the release +`.wasm` builds with an import surface of exactly WASI + +`cipherstash_transport` (`mise run wasm:guest:build` / `wasm:guest:test`). + +Both tasks run in CI. The guest is a detached workspace, so the +workspace-wide jobs never compile, lint or test it; the WASI workflow +(`.github/workflows/test-wasi.yml`) watches the guest path and runs the two +tasks, which is the crate's only gate. The import surface is asserted, not +eyeballed: `scripts/check-wasm-imports.py` parses the linked module's +import section and fails closed — every import must be either WASI (with +the capability-granting `path_*`, `sock_*` and `fd_prestat*` names denied, +so a dependency cannot quietly acquire ambient filesystem or network +access) or one of the two required `cipherstash_transport` functions, and +both of those must be present. A build alone proves nothing here: the +property is about what the *linked* module can reach. + +Three things worth stating plainly, because they are easy to read the wrong +way: + +- **Batching is one *batch*, not always one *call*.** All rows and fields + of an invocation are merged into a single pending batch, which the client + then splits into one ZeroKMS request per `ClientOpts::max_keys_per_req` + keyed leaves — 500 by default, sent sequentially (the guest pins + `max_concurrent_reqs` to 1). So "one `generate_keys` call per batch" is + exact up to 500 leaves and "one call per 500" past it. The default is + kept rather than raised: it is the server-friendly request size, and a + larger one is a promise ZeroKMS need not honour. +- **Record `"c"` leaves carry the aead-value *tagged* plaintext encoding** + (`[type tag] ++ payload`), because that tag table is the cross-language + contract Go, Node and this guest share. A Rust `#[derive(EncryptFrom)]` + over a plain primitive seals untagged bytes instead, so a plain-primitive + Rust derive and a Go plan do **not** interchange ciphertexts for the same + field until the Rust side uses aead-value's tagged types. By design; a + separate follow-up, not a defect in either side. +- **A plan context is the whole context, and it is structured.** Each + plan field's context is a string, bytes, an integer (`i32`/`i64`/`u32`/ + `u64`) or a list of those, nested as needed (the guest's `context` + module; CIP-4023, landed after Phase 3). The guest seals the field under + exactly that. A bare string is what every plan carried before — the same + AAD bytes and the same ZeroKMS descriptor as a Rust derive gives the + field when the record is sealed with `encrypt_into` (no caller context). + A list is what the Rust derive produces when it *extends* every field's + context with the caller's: `encrypt_into_with_context(row, 7u64)` seals + `users/email` under `("users/email", 7u64)`, descriptor + `users/email|7u64`, and the plan spells that as `["users/email", 7u64]` + — the same bytes on the AAD side (a list is an `AadPiece::List`, PAE of + its parts like a tuple) and on the PRF side (leaves carry vitaminc's own + typed encodings, lists are `PrfContext::pae`). Rows sealed from Rust + under a caller context open through a plan that names the same parts, and + the reverse; `se_term` takes the same form so a probe can match either. + The Go struct tag grows the extension in Phase 4 (`tenant=` or similar), + and the cross-language fixtures in Phase 5 cover both the flat and the + extended shape. + +The plan as written before the work: + +Location: `bindings/go/stackencrypt/guest/` (mirrors vitaminc's layout; +detached workspace like #2099's guest and the fuzz crates so its wasm profile +never leaks into workspace builds). Dependencies: `stack-encrypt`, +`stack-kms`, `stack-auth` (all `default-features = false`), +`vitaminc-aead-value` (FFI codec + `FfiValue`), `futures` (`block_on`), +`zeroize`. + +Reuse from vitaminc: extract `vcencrypt/guest/src/{abi,sessions,status}.rs` +buffer-registry/packing/status code into a small shared crate +(`vitaminc-wasi-abi` or similar) so both guests share one ABI implementation +instead of a copy. This is the one change the plan asks of vitaminc's guest +side. + +Exports (same conventions as `vc_*`: host owns buffers, `se_dealloc` zeroizes +via the registry, packed `u64` results, status in the low word on error): + +| export | does | +|---|---| +| `se_alloc(len)` / `se_dealloc(ptr, len)` | buffer lifecycle, as vitaminc | +| `se_cipher_init(cfg_ptr, cfg_len) → keyset id` | once per instance (CIP-4037): config (client id, client key, default keyset id/name, optional `keyset_cache_size`) encoded as an `FfiValue` object — no second codec. Builds `StackKms`, then `StackCipherBuilder::kms(..).keyset(..).init()` under `block_on` (one `load_keyset` call for the default keyset). Returns the default keyset's UUID as its buffer; a second call is `STATUS_STATE`. Client-key bytes zeroized after `ClientKey` is built. | +| `se_shutdown()` | drops the `StackCipher` (client key and every loaded index key wiped by `ZeroizeOnDrop`) and wipes every buffer the registry still holds. Needed because closing a wasm instance frees linear memory without running Rust destructors. Idempotent, and `se_alloc`/`se_dealloc` keep working so the host can still free what it holds; after it every well-formed cipher operation is `STATUS_STATE`, `se_cipher_init` included, while a malformed one is `STATUS_ENCODING` first, as in any other state. | +| `se_keyset(selector)` | resolves a keyset selector (`{"default": {}}` / `{"name": s}` / `{"id": 16 bytes}`) through the cipher's cache — the first use of a keyset is one `load_keyset` call — and returns its UUID, so a host can validate a tenant at boot and learn its id | +| `se_encrypt(value, aad, opts)` / `se_decrypt(ct, aad, opts)` | `opts` is the options object `{"keyset": }` (the guest's `options` module is its one home). Encrypt: decode `FfiValue` → `encrypt_with_aad(&keyset, aad)` → `seal(&keyset, aad)` (`block_on`) → `encode_ciphertext::`. Decrypt mirrors via `decipher(ct)` + `FfiValue::decrypt_with_aad`; its selector is a constraint — `{"any": {}}` opens leaves from whichever keyset each was sealed under (one batched `retrieve_keys` per keyset, chunked at the client's 500-key request limit), a named keyset refuses any other's leaf as `STATUS_FOREIGN_KEYSET` before any key is retrieved. | +| `se_encrypt_element` / `se_decrypt_element` | as vitaminc; row-at-a-time interop with batch-encrypted slices | +| `se_encrypt_record(source, plan, opts)` | the runtime form of `#[derive(EncryptFrom)]`: `plan` is an `FfiValue` object `{ field → { context, outputs: [ "c" \| "eq" \| "match" \| "ore" \| "ope" ] } }` — those five literals are the whole grammar, `"match"` among them (its tokenizer config is the default, not a per-output argument); per field the guest dispatches on the source `FfiValue` variant to the typed `EncryptFrom` impls (`u32`/`u64`/`i64`/`f64`/`String`), derives the terms as it builds each row and queues every ciphertext's data-key request into one flat pending list that a single `Pending::all` settles — rows and fields alike, with no per-row composition — and returns `{ field → { "c": the sealed subtree, "eq"/"match"/"ore"/"ope": the term bytes as passthrough nodes } }`, each field carrying exactly the outputs its plan asked for and under those keys. (`hm`/`oc`/`op`/`bf` are EQL's *column* names for the same bytes, not this ABI's.) One batched `generate_keys` per invocation regardless of row count, dispatched as one ZeroKMS request per 500 keyed leaves (the request limit above). | +| `se_decrypt_record(record, plan, opts)` | inverse; only the `c` outputs participate; the selector constrains as for `se_decrypt` | +| `se_term(value, context, kind, opts)` | query probe under the selected keyset's index key; `context` is codec-encoded in the plan-field grammar — one part (a string, bytes, or an `i32`/`i64`/`u32`/`u64`) or an array of parts, nested as deep as the transport codec allows (`vitaminc_aead_value::transport::MAX_DEPTH`, 128 levels from the root of the encoded value; deeper is `STATUS_ENCODING` before the context is parsed, not an interop bug). Shape is identity: `[x]` is not `x`, so a probe passes the context in exactly the shape the field was sealed under (the guest's `context` module is the one home of the grammar and of which Rust context each shape spells). Under the local HMAC backend a probe does no ZeroKMS I/O; that is the backend's property, not the API's (ZeroKMS v2 derives terms at the server) | + +Host imports (two, both from the `cipherstash_transport` module #2099 +defined): + +- `transport_send(method, url, headers, body) → (status, headers, body)` — + #2099's import generalised from its ZeroKMS shape `(endpoint, token, + body)` to a plain HTTP request, mirroring `wasi:http/outgoing-handler`. + Two reasons: `stack-auth`'s refreshers talk to CTS (a different host) and + will reuse the same import in Phase 6; and when wazero grows component + support, the WASI impl of `stack-transport` swaps this import for + `wasi:http` without changing the trait. The bearer token crosses as a + header — the same bytes cross TLS anyway. +- `token_get() → token` — Phase 1 auth: the Go host hands over a bearer + token (in the proof, minted by the harness / read from the CLI profile, + exactly as #2099's `mint-dev-token.sh` did). `HostTokenStrategy: + AuthStrategy` wraps it. Refresh stays on the host until Phase 6. + +Why host-provided HTTP and not HTTP inside the guest: wasip1 has no +`sock_connect` (receive/accept only), so outbound TCP needs a host import +regardless; TLS in the guest would mean rustls on a pure-Rust provider with +embedded roots and no AES-NI, strictly worse than Go's `crypto/tls` with +system roots; and `wasi:http` (the right long-term answer) is component +model, which wazero does not run. The host can already read guest memory, so +routing HTTP through it weakens nothing — what crosses the boundary is what +crosses TLS. + +Wasm is single-threaded and the host function blocks inside the guest call, +so the whole instance is held for the duration of a ZeroKMS round trip. Fine +for the proof; the Go side pools instances later. + +### Phase 4 — the Go module + +**Status (2026-09-12): implemented in `bindings/go/stackencrypt`** (package +path `github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt`; since +CIP-4115 the Go module is rooted at `bindings/go`, one module for every Go +package, with `internal/guest` holding what the guest packages share, +temporary until publishing). It imports `vcffi` + `vcvalue` from vitaminc +(pseudo-versioned to a main commit; no fork), embeds the guest from +`wasm/` (copied by `wasm:guest:build`, gitignored), and is gated by +`go:test` in `test-wasi.yml`. Where the shipped surface +differs from the sketch below, the shipped one follows CIP-4037: one +instance per `Client` and no cipher handle, so `NewClient` takes the +ZeroKMS credentials and initialises the cipher, `Client.Keyset(selector)` +is the keyset-bound view (the Rust `StackCipher::keyset`, returning its +`KeysetCipher`) and `Client.DefaultKeyset()` its `default_keyset`, `Client.Decrypt*` +opens any keyset, and `Term` takes a `Context` and returns an error. + +`bindings/go/stackencrypt` imports `vcvalue` for the model, and its surface +mirrors `vcencrypt` so the two feel like one SDK. As shipped, with explicit +credentials (`NewClient(ctx)` alone resolves them as the Rust client does, +from the environment and then the developer profile): + +```go +store, _ := stackauth.OpenWithoutProfile(ctx) // or stackauth.Resolve(ctx) for the profile +defer store.Close() // after the client and the strategy +strategy, _ := store.AccessKey(ctx, crn, accessKey) // or DeviceSession, OIDC, Auto +defer strategy.Close() // after the client + +client, _ := stackencrypt.NewClient(ctx, + stackencrypt.WithCredentials(stackencrypt.NewCredentials(id, stackencrypt.NewClientKey(key), strategy)), + stackencrypt.WithTransport(rt), // optional: any RoundTripper +) +defer client.Close() +cipher := client.Keyset(stackencrypt.KeysetName("users")) // or client.DefaultKeyset() + +ct, _ := cipher.Encrypt(ctx, user, aad) // map[string]any of stackencrypt.Sealed / vcvalue.Plain +pt, _ := client.Decrypt(ctx, ct, aad) // any keyset + +rows, _ := cipher.EncryptRecords(ctx, users) // one ZeroKMS call for the slice +probe, _ := cipher.Term(ctx, uint32(34), stackencrypt.MustContext("users/age"), stackencrypt.Equality) +``` + +- `stackencrypt.Sealed` — the Phase 2 leaf; `driver.Valuer` + `sql.Scanner` + like `vcvalue.Sealed`. Distinct type on purpose: a stack-encrypt leaf is + not decryptable by `vcencrypt` and must not scan into its `Sealed`. +- Record plans come from struct tags, the Go stand-in for the derive: + + ```go + type User struct { + ID int64 `stash:"plain"` + Age uint32 `stash:"context=users/age,index=eq;ore"` + Email string `stash:"context=users/email,index=eq;match"` + } + ``` + + Reflection builds the plan `FfiValue` once per type (cached) and sends + source + plan in one call. Terms come back as `stackencrypt.Term` / + `OreTerm` byte types with `Valuer`/`Scanner` and `Equal`/`Less` helpers. +- Errors: vitaminc's sentinels plus the ZeroKMS request kinds + (`ErrUnauthorized`, `ErrForbidden`, `ErrNotFound`, `ErrConflict`, + `ErrTransport`) mapped from `ViturRequestErrorKind` via status codes, so + the Go caller can distinguish a bad token from a tampered ciphertext. +- Codec: whatever the decision on the FFI codec's home is, the Go code must + not fork it. + +### Phase 5 — validation and CI + +- A CI harness for the live tests, tracked in CIP-4024 and not yet built + (the intended name is `mise run test:integration:wasi-go`, the renamed + #2099 harness). It is to boot `zerokms-server` against the mock auth + server, seed a client + keyset and an access key, then run + `CGO_ENABLED=0 go test ./...` in `bindings/go/stackencrypt` with + `STACK_ENCRYPT_TEST_CLIENT_ID`, `STACK_ENCRYPT_TEST_CLIENT_KEY`, + `STACK_ENCRYPT_TEST_CLIENT_ACCESS_KEY` and + `STACK_ENCRYPT_TEST_WORKSPACE_CRN` exported, and optionally + `STACK_ENCRYPT_TEST_CTS_HOST` and `STACK_ENCRYPT_TEST_ZEROKMS_URL`. The + same four required variables drive both paths: `NewCredentials` with a + `stackauth` access-key strategy, and `AutoCredentials` (no options to + `NewClient`). There is no raw-token variable, since the client takes + tokens only from `stackauth` strategies. `live_test.go` documents each. + Until the harness exists, the live tests are skipped unless those + variables are set, and run locally when they are. +- Go tests: import-surface gate (exactly WASI + `cipherstash_transport`), + stub-transport tests for the bridge, live encrypt/decrypt, live + `EncryptRecords` asserting **one** `transport_send` for N rows + (`TransportSends()` counter from #2099), probe-equals-stored-term, hostile + ABI inputs (port `abi_hostile_test.go`). +- Cross-language fixtures, both directions: a Rust `gen_fixture` example + (as vitaminc's) writes ciphertexts + terms + the AAD/context used; Go + decrypts and compares terms. And the reverse: Go writes a fixture the Rust + `encrypted_record` example decrypts. Terms must be byte-equal across + languages, not just "decryptable". +- CI: extend `test-stack-encrypt.yml` (from #2099) with the wasi build + + Go job; `wasm:wasi-check` in the blocking PR lane. + +### Phase 6 — follow-ups (explicitly out of the proof) + +- **`stack-transport`** — the trait crate #2099 proposed: `HttpTransport` + with reqwest and WASI-host-import impls, consumed by `stack-kms`'s + connection *and* `stack-auth`'s refreshers. That moves access-key exchange + and refresh into the guest (`AccessKeyStrategy` generic over transport), + retires `token_get`, and makes the `http` feature gates of Phase 1 + collapse into a transport choice. Bigger refactor; only start it once the + proof shows the shape is right. +- Instance pool in the Go client for parallelism; per-instance memory + limits. +- `cfg(target_arch = "wasm32")` → split JS-host vs WASI where semantics + differ (timeouts, filesystem, credential discovery). +- Component model / WIT when wazero supports it (tracked in vitaminc's + README, same decision here). +- Publishing: the `.wasm` build must be reproducible (pinned toolchain, + `opt-level = "s"`, `lto`, `strip`); the Go module ships from a public repo + — this monorepo is private, so the proof's module path is temporary. +- Retire `goencryption`'s cgo static-library matrix once parity is reached. + +## Credential guest — `stack-profile` and `stack-auth` for Go + +**Status:** decided 2026-09-20; the profile half shipped 2026-09-22 +(steps 1–4 below: CIP-4114, CIP-3997, CIP-4115 with CIP-4118, CIP-4053). +`bindings/go/stackauth` is the package, `bindings/go/stackauth/guest` the +module, `packages/stack-guest-abi` what both guests share. The transport +seam (step 5, CIP-4116) shipped separately; the strategies (step 6, +CIP-4054) are next. The decision and its rationale are +[ADR-0005](../../packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md); +this section is the sequencing only. + +Go gets the profile and auth crates through a **second** WASI module, the +credential guest, in its own package `stackauth`. The crypto guest is not +widened: it keeps no filesystem and no environment. The credential guest is +given one mounted directory (the profile root, at a fixed guest path) and, +once the auth half lands, the same `cipherstash_transport` import the crypto +guest has. The cross-process refresh lock stays on the Go side, taken around +the whole refresh export with the same `flock` / `LockFileEx` the CLI uses, +on a path the guest names. + +The steps, in order; each is a Linear issue under CIP-3764, with the +blocked-by relations set there: + +1. **`stack-profile` builds for wasm32-wasip1.** Two gates found by the + spike: `gethostname` has no wasip1 body (the creating half of + `DeviceIdentity` becomes native-only; provisioning is CLI territory), and + `std::process::id()` in the atomic write's temp name aborts the module. + Plus a public accessor for the lock file's path, and the crate joins + `wasm:wasi-check`. +2. **Shared guest ABI crate** (CIP-3997): allocator, buffer registry, the + one status table both guests use, and the transport import, extracted + into `packages/stack-guest-abi` before the second guest is written. +3. **One Go module at `bindings/go`** with an `internal` package for the + locked guest memory (CIP-4111), the status decoder and the opaque + `ClientKey` type, which both public packages expose as an alias. + `stackencrypt.Config.ClientKey` becomes that type. +4. **Credential guest + `stackauth`, profile half** (CIP-4053): the full + napi profile surface as methods on `ProfileStore`, a `TokenSource` that + re-reads the auth file per call and refuses at the real expiry, and tests + pinning wazero's 0600 create mode and the outside-mount refusal. +5. **Transport seam in `stack-auth`**: a trait mirroring the host import + (method, URL, headers, body → status, headers, body), reqwest behind + `http`, the guest import as the other impl. This is what CIP-3553 + anticipated and is the long pole. +6. **Strategies in the guest** (CIP-4054): access key, device session, OIDC + federation (Go callback for the IdP token) and auto, with the detection + order run in Go against the environment Go owns. Exchanges tested against + an in-process `httptest` server, including two goroutines racing a refresh + under the lock. +7. **CI on three platforms**: Linux builds both guests once; macOS and + Windows runners take the artifacts and run both packages' suites. Needed + before step 6 ships, since the lock has a Windows implementation. + +Steps 1, 2, 5 and 7 have no prerequisites among themselves and can run in +parallel. Step 3 stacks on CIP-4111. + +## Decisions to make first + +1. **Where the Go FFI codec lives.** `vcvalue`'s README deliberately + keeps it out of `vcvalue`; today it is unexported inside `vcencrypt`. + Options: (a) a third vitaminc module `bindings/go/vcffi` (codec + + `Encoder`/`Encryptable`, still zero external deps; the ciphertext + decoder parameterised over the leaf type so `stackencrypt.Sealed` is + not `vcvalue.Sealed`) that both `vcencrypt` + and `stackencrypt` import; (b) vendor a copy into `stackencrypt`. + Recommend (a): one codec, one set of fuzz tests. +2. **`SealedValue` byte layout** (Phase 2). Needs a version byte and the + AAD binding decision; it is a storage format from the first Go row + written. +3. **Term encodings** — align with EQL or define stack-encrypt's own. + Recommend align: Postgres is the consumer that matters. +4. **Phase-1 auth**: host-supplied token (`token_get`) versus doing + `stack-transport` first. Recommend host-supplied — it is what #2099 + proved, it keeps the proof to one new seam, and the refactor is better + informed after the proof. +5. **Feature vs `cfg(target_os = "wasi")`** for gating reqwest in + `stack-kms`/`stack-auth`. A feature is honest about "this build has no + HTTP" and lets native tests exercise the HTTP-free path; a cfg is + invisible to callers. Recommend the feature (`http`, default on). +6. **Repo location / module path** for the proof: `bindings/go/stackencrypt` + in this repo (mirrors vitaminc) versus straight into a new public SDK + repo. Recommend here for the proof; it needs the integration harness. + +## Non-goals for the proof + +- Typed Rust-static ↔ Go interop (vitaminc's decision 4 stands: fidelity + comes from the schema/plan layer, not value tags). +- Device/OIDC/access-key auth flows inside the guest (Phase 6). +- EQL wire payloads (`{v,i,c,ob}` JSON) — that is `eql-bindings`' layer; the + Go binding returns the record tree and leaves EQL framing to a schema-aware + consumer, exactly as the Rust side does. +- Performance beyond "one ZeroKMS call per batch". diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md new file mode 100644 index 000000000..44676d96e --- /dev/null +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -0,0 +1,648 @@ +# RFC 0002 — The async shape of target-directed encryption + +| | | +| -- | -- | +| **Status** | Accepted — implemented on PR #2146 (sem visitors) and #2147 (target layer) | +| **Author** | Dan Draper | +| **Area** | `packages/stack-encrypt` (`target`, `sem`, `cipher`), `vitaminc` (`prf`) | +| **Supersedes** | The "Batching and async" section of `target-directed-encryption.md` (whose snippets have since been reconciled with the shipped API) | +| **Prompted by** | Review of PR #2147 | + +> **Companion:** [`target-directed-encryption.md`](../target-directed-encryption.md) — +> the design: *what* the target type decides. Its snippets match the shipped +> API. This RFC specifies *how the async is shaped*, which the first +> implementation got wrong. + +## 1. Summary + +`EncryptFrom` as implemented in #2147 hardcodes a boxed future as its return +type. Three consequences: + +1. Every cipher is forced to be async, including ones that do no I/O. +2. Nothing can be batched — not the fields of one record, not a column of + rows. A five-row insert is five ZeroKMS round-trips where `Encrypt` alone + would make one. +3. The single 0KMS operation that will derive data keys **and** PRF values + together is not merely unused, it is unreachable: by the time a composite + sees its fields, each is a sealed-shut future with no inspectable requests. + +The fix is to apply the rule vitaminc already follows everywhere else — +**build synchronously, settle once** — and to let the *cipher* own the output +type, exactly as `Cipher::Ok` and `Prf::Ok` already do. + +Call sites do not get worse. They get shorter. + +## 2. What is wrong today + +### 2.1 The trait decides the async, not the cipher + +```rust +// packages/stack-encrypt/src/target.rs +pub type PendingEncrypt<'a, T, E> = Pin> + Send + 'a>>; + +fn encrypt_from<'a, 'c, Ctx>(source: &'a S, cipher: &'a C, context: Ctx) + -> PendingEncrypt<'a, Self, Self::Error>; +``` + +`EncryptFrom` is the only trait in the stack that does this. Its two +neighbours both hand the choice to the implementation: + +| trait | output | who decides | +| -- | -- | -- | +| `Cipher::Ok` | `PendingStackCipherText` for `StackCipher`; a finished ciphertext for a local cipher | the cipher | +| `Prf::Ok` | `ReadyPrf` for `HmacSha256Prf`; a real future for a future 2-party backend | the backend | +| `EncryptFrom` | `Pin>`, always | **the trait** | + +A cipher that does no I/O still returns a future the caller must `.await`. + +### 2.2 Nothing batches + +`StackCipherText::encrypt_from` calls `cipher.encrypt(value, aad)`, which is +`encrypt_with_aad` **plus** `pending.seal(..)` — it settles immediately. So the +pending tree that exists precisely so leaves can share one `generate_keys` +call is built and consumed inside a single leaf. + +The cost, from `examples/encrypted_record.rs`: + +```rust +for age in ages { // 5 ages + let record: EncryptedInt = age.encrypt_into(&cipher, CONTEXT).await?; + table.push(record); +} +``` + +Five `generate_keys` round-trips. The same five values through `Encrypt` +alone — `cipher.encrypt(vec_of_ages, aad)` — are **one**, because +`encrypt_seq` builds one tree and `key_count()` sums its leaves into one +payload batch. + +The read path in the same example has the identical defect: one +`retrieve_keys` per row, in a loop. + +The root cause is not the loop. It is that there is no `Vec` implementation, +so a column *cannot* be expressed as a single operation the way `Encrypt` +expresses it. + +### 2.3 `try_join!` does not do what its comment claims + +```rust +/// For that batching to be possible across a record's fields, composite +/// implementations must poll their field pendings **concurrently** +/// (e.g. `tokio::try_join!`), never sequentially. +``` + +Concurrency is not coalescing. Three independently constructed futures polled +at once issue three requests. Coalescing needs a shared request collector, and +there is nowhere to put one — each future has already closed over its inputs +before `try_join!` sees it. + +(Today only the ciphertext branch does I/O, so a record costs one round-trip, +not three. The comment is still wrong about why, and the shape it recommends +is what blocks §2.2.) + +### 2.4 SEM shaping happens inside the async + +vitaminc's PRF already has the right seam: the backend produces blocks, and a +`PrfVisitor` turns blocks into whatever shape the caller wants — +`prf_visit_with_context(prf, ctx, visitor) -> P::Ok`. Nothing about +Bloom positions or CLLW ciphertexts needs to be async. + +`sem`'s `derive_match` uses that seam correctly (`BloomVisitor`). The other +three do not: + +```rust +// derive_equality — BlockVisitor, then shape in async code +let block = value.prf_with_context(prf, context).await?; +Ok(EqualityTerm(block)) + +// derive_cllw_key + derive_ore — BlockVisitor, then shape in async code +let block = context_bytes.prf_with_context(prf, ..).await?; +let key = cllw_ore::Key::from(block); +value.encrypt(&key) +``` + +Worse, all four `derive_*` are `async fn`, which collapses `P::Ok` +into an `.await` **inside stack-encrypt** — throwing away the backend's choice +of output type before the cipher ever sees it. That is the same mistake as +§2.2, one layer down: a deferred handle destroyed by the code that should have +been passing it along. + +Pure validation (`require_context`, `MatchOptions::validate`, +`EmptyTermText`) also runs inside the async body, so a malformed call fails +after a round-trip rather than before one. + +## 3. The rule + +> **Build synchronously. Settle once. The settle point belongs to the cipher.** + +vitaminc obeys this: `Encrypt` drives a `Cipher` with no I/O and yields +`Cipher::Ok`; whoever holds the `Ok` decides when — and how many at a time — +to settle. `EncryptFrom` must obey it too. + +## 4. Design + +### 4.1 The cipher owns the output type + +```rust +/// Implemented by ciphers. Decides what `encrypt_from` hands back. +pub trait EncryptTarget { + type Error; + type Output<'a, T: 'a>: 'a where Self: 'a; +} + +pub trait EncryptFrom: Sized { + fn encrypt_from<'a>(source: &'a S, cipher: &'a C, ctx: Ctx) -> C::Output<'a, Self> + where + Self: 'a; +} +``` + +(`Ctx` became a trait parameter after this RFC was accepted — see the +deviations in §7. The shape argued for here is unchanged by it.) + +- A synchronous cipher sets `Output<'a, T> = Result`. No + future, no `.await`. +- `StackCipher` sets `Output<'a, T> = PendingEncrypted<'a, T, K>` (§4.3), + which implements `IntoFuture`. + +```rust +let t: EqualityTerm = "alice".encrypt_into_with_context(&stack_cipher, "users/email").await?; // async backend +let t: LocalTerm = "alice".encrypt_into_with_context(&local_cipher, "users/email")?; // sync backend +``` + +(The bounds above are the shape, not the final spelling — `K: 'a` and the +GAT's implied bounds will surface during implementation. What must not change +is *where* the type is chosen.) + +### 4.2 Type inference: why this works and the earlier attempt did not + +The implementation notes record that an associated `Pending` type was tried +and defeated `let term: EqualityTerm = v.encrypt_into(..).await?`. That is +correct **for an associated type on the target**: normalizing `T::Pending` +requires selecting the `EncryptFrom` impl, which requires knowing `T` — the +very thing being inferred. + +`C::Output<'a, T>` has no such cycle. Normalizing it requires only `C`, and +`C` is concrete at every call site (`&StackCipher`). `T` survives as a +syntactic parameter of a concrete struct, exactly as it does in today's +`Pin>>>`, so the `.await?` unifies `T` +with the annotated binding through `PendingEncrypted`'s `IntoFuture` impl. + +This distinction is the load-bearing part of the design and should be pinned +by a compile test. + +### 4.3 `PendingEncrypted` — a request carrier, not a future + +```rust +pub struct PendingEncrypted<'a, T, K> { + cipher: &'a StackCipher, + requests: Vec, // Request::DataKey | Request::Prf + fulfil: FulfilBox<'a, T>, +} + +// The Send split mirrors today's `PendingEncrypt` alias and MUST carry over: +// the ZeroKMS futures are not `Send` on wasm32. +#[cfg(not(target_arch = "wasm32"))] +type FulfilBox<'a, T> = Box Result + Send + 'a>; +#[cfg(target_arch = "wasm32")] +type FulfilBox<'a, T> = Box Result + 'a>; + +impl<'a, T, K: DataKeySource> IntoFuture for PendingEncrypted<'a, T, K> { + type Output = Result; + // Boxed future, cfg-split on Send exactly as above. + fn into_future(self) -> ... { + Box::pin(async move { + let responses = dispatch(self.cipher, self.requests).await?; // ONE call + (self.fulfil)(responses) + }) + } +} +``` + +Carrying `K` is what lets the type name the cipher it settles against; the +`EncryptTarget` impl is per-`K`, so `Output<'a, T> = PendingEncrypted<'a, T, K>` +is well-formed. (The alternative — erasing `K` behind a boxed dispatch +closure captured at construction — keeps the type two-parameter at the cost +of a second allocation per pending. Either works; carrying `K` is the default +because it is simpler and the type rarely appears in signatures outside +`encrypt_from`.) + +`into_future` is the **only** place I/O happens, and the only place that knows +how to talk to 0KMS. With an empty request list it short-circuits: a +term-only target does zero round-trips. Requests are heterogeneous +(`DataKey` now, `Prf` later); a `fulfil` that draws a response of the wrong +variant — or the wrong count — is a composition bug and settles as an error +(`Error::ResponseShape`), never a panic. + +Its API is small and is **the public surface third-party targets build +against** (§4.7): + +```rust +impl<'a, T, K> PendingEncrypted<'a, T, K> { + pub fn ready(cipher: &'a StackCipher, result: Result) -> Self; + pub fn request(cipher: &'a StackCipher, requests: Vec, fulfil: ...) -> Self; + pub fn map(self, f: impl FnOnce(T) -> U + ...) -> PendingEncrypted<'a, U, K>; + pub fn zip(self, other: PendingEncrypted<'a, U, K>) -> PendingEncrypted<'a, (T, U), K>; + // zip3 / zipN as needed; `all` for Vec (§4.4) + pub fn all(items: Vec>) -> PendingEncrypted<'a, Vec, K>; +} +``` + +`zip` concatenates request vectors and splits the response vector back by +recorded length, so no `fulfil` can over-draw its neighbours' responses. + +`StackCipherText::encrypt_from` stops calling `cipher.encrypt` and instead +keeps the tree it was always meant to keep: + +```rust +let tree = value.encrypt_with_aad(cipher, aad)?; // PendingStackCipherText, no I/O +requests = (0..tree.key_count()).map(|_| Request::data_key()), +fulfil = move |keys| tree.seal_with(&mut keys.into_iter()) +``` + +`key_count()` + `seal_with`'s draw-in-traversal-order is already the exact +invariant `zip` needs. The design is the existing machinery applied one level +up. + +### 4.4 Composition is where batching comes from + +Composites combine pendings **without awaiting them**, so requests merge: + +```rust +impl<'c, K, T: IntoAad<'c> + IntoPrfContext<'c> + Clone> EncryptFrom, NonEmpty> for EncryptedInt { + fn encrypt_from<'a>(source: &'a u32, cipher: &'a StackCipher, ctx: NonEmpty) + -> PendingEncrypted<'a, Self, K> + { + StackCipherText::encrypt_from(source, cipher, ctx.clone()) + .zip(EqualityTerm::encrypt_from(source, cipher, ctx.clone())) + .zip(OreTerm::::encrypt_from(source, cipher, ctx)) + .map(|((ciphertext, eq), ord)| Self { ciphertext, eq, ord }) + } +} +``` + +No `tokio::try_join!`, no `Box::pin(async move ..)`, no error-conversion +where-clauses. This is roughly half the size of the current impl and is +directly emittable by `#[derive(EncryptFrom)]` (which is what the derive does — +see §7). + +Then the missing piece from §2.2: + +```rust +impl EncryptFrom, StackCipher, Ctx> for Vec where T: EncryptFrom, Ctx>, Ctx: Clone +impl EncryptFrom, StackCipher, Ctx> for Option +``` + +which makes the column one operation, one await, one round-trip: + +```rust +let table: Vec = ages.encrypt_into_with_context(&cipher, CONTEXT).await?; +``` + +Batching comes from the **source shape**, exactly as it does for `Encrypt`. +There is no `seal_all`, no flush handle, and no two-step call site. + +Elements of a `Vec` share one context deliberately: a column is one context. +(`Encrypt` separately refines per-element AAD via `Aad::for_sequence_element`; +the PRF context is not refined, so equal values in a column derive equal +terms — which is the point of an index.) + +A caller who awaits per element still pays per element. That is true of +`Encrypt` too, is visible at the call site, and is acceptable. + +### 4.5 SEM terms become visitors + +Delete the four `derive_*` functions. Each term's `encrypt_from` does its pure +work up front, then derives through `prf_visit_with_context` with a visitor +that shapes the block: + +| term | visitor | shaping | +| -- | -- | -- | +| `EqualityTerm` | `EqualityVisitor` | block → term | +| `MatchTerm` | `BloomVisitor { k, mask }` | already correct; keep | +| `OreTerm` | `OreVisitor(value)` | block → CLLW key → encrypt → term, all inside the visitor; the key never leaves | +| `OpeTerm` | `OpeVisitor(value)` | as above, under the OPE domain | + +`require_context`, `MatchOptions::validate` and the empty-token check move +ahead of the request, where they fail without a round-trip. The visitor owns +the plaintext it needs, which removes the clone-into-async-fn each term does +today and narrows the plaintext fan-out the module docs warn about — only the +ciphertext branch still needs an owned copy held until seal. + +Owning the plaintext is not incidental for ORE/OPE, and it has a cost. The +cost: a visitor is `'static`, so ORE/OPE sources are `Send + 'static` — +literals still work, borrowed text becomes a `String` (`cllw-ore` gained +`CllwOreEncrypt`/`CllwOpeEncrypt` for `String` and `Vec`, byte-identical +to the borrowed impls). The reason: under the two-party PRF no CLLW key exists +on either side, so a visitor that *returns* a key — which is what the first +implementation did (`CllwKeyVisitor`, encrypting after the visitor) — has +nothing to return. The visitor has to be the whole ORE operation: PRF input +in, ciphertext out. Callers then see the surface the two-party backend will +have, and the key is a private detail of the local backend's visitor. + +**How the value comes out synchronously.** No `SyncPrf` marker trait is +needed, but the mechanism deserves stating, because it is concrete-type +knowledge, not trait knowledge: term impls bind `StackCipher`, whose PRF is +concretely `HmacSha256Prf`, whose `Ok` is `ReadyPrf` — +and `ReadyPrf::into_result()` extracts without an executor. So today a term's +`encrypt_from` is: + +```rust +let term = tokens + .prf_visit_with_context(cipher.prf(), context, BloomVisitor { k, mask }) + .into_result() // ReadyPrf: sync, infallible backend + .map_err(TermError::from_prf); +PendingEncrypted::ready(cipher, term.map_err(Error::from)) +``` + +**The migration path is the argument for the visitor seam.** When the 2-party +ZeroKMS PRF backend replaces the local HMAC inside `StackCipher`, a term's +`encrypt_from` changes in exactly one way: instead of invoking the visitor +inline over a `ReadyPrf`, it pushes `Request::Prf { input, context }` and +invokes the **same visitor** inside `fulfil`, over the blocks that came back +in the batch response. The shaping code — Bloom positions, equality blocks — +does not change, because the visitor never knew which side of the round-trip +it ran on. ORE/OPE are the one place the visitor *internals* change, and they +prove the seam rather than break it: CLLW under a two-party PRF has no key, +so `OreVisitor` moves from `visit_block` (block → key → encrypt) to +`visit_seq` over per-prefix PRF outputs (one per plaintext bit), while +`OreTerm`'s `encrypt_from` and every call site stay exactly as they are. That swap is also what fuses terms and data +keys into the single combined 0KMS call: both are then rows in one +`requests` vector settled by one `dispatch`. + +### 4.6 Errors belong to the cipher + +`C::Output<'a, T>` has no error slot, so the error is `C::Error`, and +`EncryptFrom::Error` is dropped. `TargetError` and the six-line +error-conversion where-clauses on every composite go with it. Term errors +reach the cipher's error through `Error::Term(#[from] TermError)`; third-party +terms get an `Error::Other(Box)` escape; +`Error::ResponseShape` covers a mis-drawn response (§4.3). + +### 4.7 The third-party recipe, revised + +The current module docs teach external term authors to return +`Box::pin(async move ..)`. The replacement is shorter and does no async at +all until a deferred PRF exists: + +```rust +impl<'c, S, K, T> EncryptFrom, NonEmpty> for MyTerm +where + S: PrfValue + Clone, + T: IntoPrfContext<'c>, +{ + fn encrypt_from<'a>( + source: &'a S, + cipher: &'a StackCipher, + context: NonEmpty, + ) -> PendingEncrypted<'a, Self, K> + where + Self: 'a, + { + let context = context.into_prf_context().into_owned(); + let context = PrfContext::pae(&[b"my-crate/my-term/v1".as_slice(), context.as_bytes()]); + let term = source + .clone() + .prf_visit_with_context(cipher.prf(), context, MyVisitor) + .into_result() + .map(MyTerm) + .map_err(|e| Error::Other(Box::new(e))); + PendingEncrypted::ready(cipher, term) + } +} +``` + +This commits `PendingEncrypted::ready` / `::request` — and therefore +`Request` (and enough of `Responses` for a `fulfil` to draw from) — to the +public API. That is deliberate: an extension point that only first-party code +can use is not an extension point. See §7.3 for what stays private. + +## 5. Invariant this imposes on future backends + +**A deferred output type must be a mergeable request carrier, not a +self-driving future.** + +This is why `Cipher::Ok` is `PendingStackCipherText` rather than a future, and +it must hold for the 2-party ZeroKMS PRF backend too: if its `Prf::Ok` is +`Pin>`, terms and data keys can never share a round-trip, and +the combined keys-and-PRF 0KMS operation becomes unreachable — silently, from +an implementation that looks perfectly reasonable in isolation. + +Honesty about enforcement: today this is **guidance, not mechanism**. Nothing +in the `Prf` trait lets a caller decompose a foreign `Ok` into requests +plus a continuation; `StackCipher` will merge PRF work by *being the caller* +of its own backend (§4.5), not by prying open a generic `P::Ok`. Making the +invariant structural — an `into_parts()`-style decomposition on deferred +outputs — is a future `Prf` trait extension, and should be designed with the +2-party backend, not before it. Until then the rustdoc on `Prf::Ok` / +`Cipher::Ok` can only warn (§8). + +## 6. Impact + +| file | change | +| -- | -- | +| `src/target/{mod,pending,request}.rs` | `EncryptTarget` + GAT; `Pending` (with the wasm32 `Send` cfg-split carried over from `PendingEncrypt`); drop `PendingEncrypt` alias, `EncryptFrom::Error`, `TargetError`. Split in review so `Pending` and `Request`/`Responses` carry their own unit tests | +| `src/sem/mod.rs` | four visitors in, four `derive_*` out; validation moves ahead of the request; ORE/OPE encrypt inside the visitor (`Send + 'static` sources) | +| `cllw-ore` (in cipherstash-suite) | `CllwOreEncrypt`/`CllwOpeEncrypt` for `String` and `Vec`, delegating to the borrowed impls | +| `src/cipher.rs` | `StackCipher: EncryptTarget`; `dispatch`; `Error::Term`/`Error::Other`/`Error::ResponseShape` | +| `examples/`, `tests/` | column encrypted as a `Vec`, not a loop; `try_join!` gone; tokio dev-dep drops out of the record shape | + +Wire format is untouched. `tests/term_bytes.rs` is the guard: the four pinned +derivations must produce identical bytes before and after. + +## 7. Decisions — resolved + +1. **Composite impls bind `StackCipher`** (decided): records carrying SEM + terms need a PRF that vitaminc's ciphers do not have, and the derive emits + concrete code either way. The `ready`/`map`/`zip`/`all` combinators live on + `Pending`; they lift onto `EncryptTarget` if a second async cipher ever + appears. +2. **Decrypt landed with this change** (decided): `DecryptTarget`, + `DecryptInto`, `DecryptFrom` and `DecryptContext` mirror the encrypt side; + the `Vec` implementation batches a column of rows into one + `retrieve_keys`. The derive will emit both directions from day one. + Which half of each `From`/`Into` pair is the implementable one follows + from where `Self` lands: the record is the *output* of encryption and the + *input* of decryption, and it is the only type a downstream crate can + implement on, so `EncryptFrom

` and `DecryptInto

` are implemented + (and derived) on the record while `EncryptInto` and `DecryptFrom` are + blanket call-site sugar. An encrypted type may decrypt to several + plaintexts and several encrypted types (the EQL integer payloads, say) to + one plaintext; each owns its own opening. vitaminc's `Decrypt` — the + plaintext's *bytes → value* step, the same for every ciphertext shape — + stays underneath as the leaf. +3. **`Request` is public but opaque** (decided): constructors only + (`Request::generate_data_key()`, `Request::retrieve_data_key(iv, tag)`, + later a PRF request and a keyset override), internals private. `Responses` + is a drawing handle (`next_generated_key()` / `next_retrieved_key()`), + never inspectable, and each fulfilment is scoped to exactly the responses + its own requests asked for — over-drawing is `Error::ResponseShape`, not a + sibling's stolen key. +4. **Per-field keysets are achievable in this shape** + ([CIP-3870](https://linear.app/cipherstash/issue/CIP-3870)), and the + request-carrier design is specifically what makes them so: `Request` being + opaque means a keyset override field is a non-breaking addition; `dispatch` + then groups requests by keyset and issues one call per distinct keyset, + re-zipping responses into draw order — no trait or `Pending` surface + change. Per-keyset *terms* need per-keyset index keys, so "load the index + key for keyset X" becomes a request itself, with the visitor running in the + fulfilment. The genuine blocker is decrypt: `SealedValue` records no + keyset, so per-leaf retrieve routing needs the wire change already parked + in CIP-3870. + +### Deviations from the proposal above + +The implementation kept the design and changed three names/details: + +- **`Pending<'a, T, K>`**, not `PendingEncrypted` — one carrier serves both + directions (it is `DecryptTarget::Output` too), so the direction is not in + its name. +- **`DecryptContext`** (`IntoAad + Clone`) joined `EncryptContext`: decryption + derives nothing, so it must not demand a PRF conversion. +- **The context is a trait parameter** — `EncryptFrom`, + `DecryptInto` — not a method generic. A method generic is bound + once, by the trait, so no implementation could refuse a context it cannot + use; as a trait parameter each impl bounds it. The leaves demand + `SuppliedContext` (every vitaminc context type but `()`), records inherit + that through their field bounds, and a row whose fields carry their own + contexts is implemented for `()` alone — a context handed to it would go + nowhere. The sugar splits accordingly, after + vitaminc's `encrypt` / `encrypt_with_aad`: `encrypt_into(&cipher)` passes + `()` and exists only for outputs that need nothing from the caller; + `encrypt_into_with_context(&cipher, ctx)` for the rest (`decrypt_from` / + `decrypt_from_with_context` mirror it). Which applies is the type's + decision, made at compile time; runtime rejection is left to what the + type cannot see — an empty string — pending vitaminc#291. +- **Contexts are vitaminc's `NonEmpty`, and a caller's context extends + a field's own** (2026-09-04, after vitaminc 0.2.0 shipped `NonEmpty`). + `SuppliedContext`, `EncryptContext` and `DecryptContext` are gone: a leaf + is implemented for `NonEmpty` alone (`T: IntoAad + IntoPrfContext`), so + `()` is a compile error against it and `""` cannot be built, with no + runtime emptiness check anywhere in this crate — the pre-request + `require_context` step in §4.5 went with it. A derived record is + implemented twice — for `()`, each field under the context it carries + itself (a `context = ".."` literal, or the one a `struct = ..` derive + infers), and for `NonEmpty`, each field under that context extended + with the caller's (`("users/age", id)`) — so `user.encrypt_into(&cipher)` + and `user.encrypt_into_with_context(&cipher, id)` both compile, the second + binding every field to its record, and no record accepts a context it + then discards. The `_with_context` sugar takes anything that converts + into a `NonEmpty`: `nonempty!("..")`, `NonEmpty::new(value)?`, a bare + integer. `row = ..` became `struct = ..` (2026-09-05: "row" pushed + database vocabulary into a general-purpose library), and `from` / + `nested` exist only there — `plaintext = T` derives every field from the + whole value whatever `T` is. +- **`dispatch` issues one call per request *kind*** (at most one + `generate_keys` + one `retrieve_keys`, sequentially — a mixed batch is rare + today). When ZeroKMS grows the combined keys-plus-PRF operation, `dispatch` + is the one function that changes. + +Review of #2146/#2147 then corrected three more: + +- **`EncryptFrom` / `DecryptFrom`** — first shipped as `EncryptedFrom` / + `DecryptedFrom`; renamed to the names this RFC uses. +- **`target.rs` became `target/{mod,pending,request}.rs`** so the request + carrier and the response handle have unit tests of their own (call counts + per batch, per-kind response scoping, over-draw, zero-I/O `ready`). +- **ORE/OPE first shipped as `CllwKeyVisitor`** — a visitor that returned the + CLLW key, with encryption after it. Reverted to the §4.5 shape + (`OreVisitor(value)` / `OpeVisitor(value)`); §4.5 records why a + key-returning visitor cannot survive the two-party backend. Wire format + unchanged (`tests/term_bytes.rs`). + +The final review then held the implementation to two of this RFC's own +claims: + +- **"`dispatch` is the one place that changes" was not yet true.** The + cipher-directed `seal` and `decipher` carried their own copies of the + ZeroKMS plumbing. They now build a `Pending` through the same + `seal_pending` / `decipher_pending` builders the target impls use and + settle it via a crate-private, unboxed `Pending::settle`; there is one + walker, one wire convention, one dispatch, and a test that ciphertext from + either API opens under the other. +- **"Rejected during the synchronous build, before any I/O" had gaps.** + `Pending` now records a build-time failure and `zip`/`all` drop the + assembly's requests when one side has failed, so a misconfigured field + never mints keys for its siblings; the empty-context guard is structural + over PAE (so `None` / `Some("")` / tuples of empties are caught); and + merging pendings from different ciphers is `Error::CipherMismatch` rather + than a `debug_assert`. + +### `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]` (follow-up PR) + +The derive emits exactly the §4.4 shape — one impl over `StackCipher`, +field pendings zipped and mapped, never awaited — for a struct of leaves, and +one level up for a *struct record*: a struct whose fields are each derived +from a field of the source (`from = ..`, inferred from the field's own name) +under an *own context* — `"/"`, inferred, or a +`context = ".."` literal. An own context is never discarded: a caller's +context **extends** it (`("users/age", id)` under +`encrypt_into_with_context(&cipher, id)`), so a record sealed with +`encrypt_into` opens with `decrypt_from` and one sealed under an extension +opens only under the same extension, and a query site derives its term under +the same own context, extended the same way. This is how a field is bound to +its record as well as its name without the type knowing the id. (First +shipped the other way round — field literals *replacing* the record's +context, the caller passing `()` — which let a literal-only record accept a +context and seal nothing under it; #2180 made extension the rule. The +contract is stated in `packages/stack-encrypt/CONTEXT.md` under "Own +context" and in ADR-0001.) + +The final-review "empty context" guard above moved from a runtime check to +the types in the same change: a leaf takes vitaminc's `NonEmpty` and `()` +is a compile error against it, so `Vec` and `Option` have no emptiness to +check and pass the context through. What a column does check, once before +walking its elements, is that a context whose elements bind ZeroKMS keys +renders within the descriptor limit (`ElementContext`); an empty column and +a column of terms are not held to it. The "fail on the fixture with no +rows" property went with the runtime check — a misconfigured context is now +a type error rather than a value the first row catches. + +The derive is bound to `StackCipher` rather than generic over +`EncryptTarget`, because combining outputs needs `zip`/`map` and only +`Pending` has them; a generic derive would need those as `EncryptTarget` +methods, and that extension does not change the attribute surface. `from` +fields carry no where clause (the plaintext field's type is not visible to +the macro), so their obligations are checked in the impl body. Rows — +decrypted field by field — must name their `plaintext`, because the derive +rebuilds it with a struct literal; a record opened as a whole may leave it +off and decrypt to whatever its ciphertext field opens to. + +The derives are named after the trait they emit, as serde's are, and the +attribute after the crate: `#[stash(plaintext = ..)]` for a record, +`#[stash(struct = .., context = "..")]` for a struct encrypted field by +field (shipped as `row = ..`) — which infers every field's +`from` (its own name) and the field half of its context +(`"/

"`; the prefix is the required container +`context`, given explicitly because it is stored-data identity and must not +follow a Rust type's name), with `#[stash(from = .., context = "..")]` and +`#[stash(nested)]` as the per-field overrides. Which +field decryption opens is not an attribute either but a property of the +field types (`Decryptable`), checked at compile time to be exactly one; +`decrypt` is the override for records the types cannot settle. First shipped as +`#[derive(Encrypted, Decrypted)]` with `#[encrypted(source = ..)]`: the +decrypt macro sat on the record but emitted `DecryptFrom<Record> for +Plaintext`, a trait whose `Self` was not the annotated type, and the +attribute name lined up with neither derive. Flipping the decrypt trait (item +2 in §7) is what let the macro be named honestly. + +## 8. Where findings get recorded + +Three homes, by durability: + +- **Trait invariants** (§5, and "the visitor shapes, the backend only + produces blocks") → rustdoc on `Prf::Ok<T>` in `src/traits.rs` of vitaminc's `prf` crate, + and on `Cipher::Ok` in `src/cipher.rs` of its `aead` crate. A backend + author reads the trait, not this repo's RFC directory. These are the two + places where getting it wrong is invisible until it is expensive. +- **The reasoning** (§2–§4) → this RFC. It explains why the obvious + implementation is wrong, which rustdoc is the wrong length for. +- **The work** → Linear under CIP-3764. + +## 9. Non-goals + +- Changing what the target type decides. `target-directed-encryption.md` + stands. +- Wire format changes. +- A flush handle, an ambient batch registry, or timing-window coalescing. + Batching is expressed by the source shape and is visible at the call site. diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md new file mode 100644 index 000000000..d3d0f55ca --- /dev/null +++ b/docs/target-directed-encryption.md @@ -0,0 +1,315 @@ +# Target-directed encryption + +**Status:** design record, reconciled with the shipped API on 2026-08-28 and again on 2026-09-04 (contexts are vitaminc's `NonEmpty<T>`; a caller's context *extends* a field's own; `row = ..` became `struct = ..`, and `from` exists only there). The snippets below are the API as it ships in `stack-encrypt` (cipherstash-suite #2146, #2147); the async shape is specified by [RFC 0002](rfcs/0002-async-shape-for-target-directed-encryption.md). +**Date:** 2026-08-21 +**Scope:** vitaminc (primitives), stack-encrypt (the new trait + batching), eql-bindings (one class of targets) + +## Implementation notes + +Everything this document decides shipped as designed: one trait on the output type, leaves handwritten, composites assembled from leaves, context threaded per value, ORE held rather than grown into vitaminc. Three things differ from the original sketches and are marked inline where they appear: + +- **The derives are named after the traits they emit**, not `Encrypted`: `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]` in `stack-encrypt-derive`, under `#[stash(..)]` attributes. Hand-written impls remain the way to write a leaf (`packages/stack-encrypt/examples/encrypted_record.rs` shows one composite written out). +- **ORE/OPE use `cllw-ore`, not `ore-rs`,** and the per-field key is derived through the PRF *inside* the term — there is no `ProvidesOre` accessor and no key is ever handed back. +- **An empty context is rejected**, not permitted. `Aad::empty()` was proposed for non-EQL callers; the leaves take vitaminc's `NonEmpty<T>` and nothing else, so an empty one cannot be built (`NonEmpty::new` refuses it once; `nonempty!("")` does not compile) and `()` — what `encrypt_into` passes — is a compile error against a leaf. + +## Problem + +A stored encrypted value is rarely just a ciphertext. It is a *record*: the AEAD ciphertext of the plaintext, plus zero or more search terms derived from the same plaintext by different primitives, plus some metadata. EQL's `public.eql_v3_integer_ord_ore` is one instance — + +``` +{ v: schema version, i: identifier, c: ciphertext, ob: block-ORE term } +``` + +— but the shape is general. Any scheme that stores "the ciphertext and some derived terms alongside it" has it. + +vitaminc today gives us the ciphertext (`Encrypt` / `Cipher`) and a PRF (`PrfValue` / `Prf`), each excellent at its own job and each producing *one* output. Nothing composes them into a record, decides which terms a given record needs, or lets one plaintext fan out to several primitives in a single batch. + +We want the target type to answer all three questions, so that this compiles only when the pieces line up: + +```rust +let x: IntegerOrdOre = 10.encrypt_into_with_context(&cipher, nonempty!("users/age")).await?; +``` + +**This must not be EQL-specific.** EQL payloads are one class of output. Nothing in the mechanism should know what a table or a column is. + +## Prior art: the async-sync spike + +`_spikes/async-sync/src/ore.rs` takes the **input-driven** route: a trait per index type, implemented per plaintext type. + +```rust +pub trait OreEncrypt: Sized { + fn encrypt_ore<C: OreCipher>(self, cipher: C) -> Composite<Self>; +} +pub struct Composite<T>(pub T, pub OreTerm); +``` + +`Composite` then implements `Encrypt` to lay out the map, and `EqlBuilder::with_ore(cipher)` stacks terms onto a value. + +It works, and one idea in it is worth keeping (see [Analysis vs derivation](#analysis-vs-derivation)). Four things break at scale: + +1. **No compile-time tie to a target shape.** `IntegerOrdOre` is `deny_unknown_fields` over exactly `v,i,c,ob`. A builder chain `.with_ore().with_eq()` produces `v,i,c,ob,hm`, which is not a domain. The shape is checked at Postgres, not by rustc. +2. **Combinatorics.** One trait per index × per plaintext type. `text_search` wants eq + ore + bloom: three traits, three calls, and the caller has to know which. +3. **Sync only.** `encrypt_ore` returns a value. ORE is local and sync; ZeroKMS-derived terms are async and batched. The spike has nowhere to join them. +4. **`Composite` writes the source back** (`self.value = val`), forcing the plaintext through the index path even when the index only needs to read it. + +The root cause of 1 and 2 is direction: the *caller* assembles the record, so the type system never sees the record as a whole. + +## Design + +Invert it. One trait, on the output type, describing what that type is: + +```rust +/// `Self` is an encrypted representation of `S`, producible by a cipher `C`, +/// under a context `Ctx`. +pub trait EncryptFrom<S, C: EncryptTarget, Ctx>: Sized { + fn encrypt_from<'a>(source: &'a S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> + where + Self: 'a; +} + +/// Implemented by ciphers: decides what an `EncryptFrom` implementation hands back. +pub trait EncryptTarget { + type Error; + type Output<'a, T> where Self: 'a, T: 'a; +} +``` + +Reads as a noun: *`EqualityTerm` is an encrypted form of `&str`*. + +The output shape belongs to the **cipher**, not the trait — the same rule vitaminc follows for `Cipher::Ok` and `Prf::Ok<T>`. A cipher that does no I/O sets `Output<'a, T> = Result<T, Self::Error>`: no future, no `.await`. `StackCipher` sets `Output<'a, T> = Pending<'a, T, K>`, a request carrier: a local term resolves immediately, a ciphertext queues its data-key request, and merged pendings settle in one batched ZeroKMS call when awaited. The trait fixes neither a future nor an error type; RFC 0002 records why it must not. + +The context is a parameter of the trait, not of the method, so that an implementation can say which contexts it accepts — see [Context](#context) below. The call-site sugar is `Into` over `From` — blanket, never implemented by hand — in two forms, the split of vitaminc's `encrypt` / `encrypt_with_aad`: + +```rust +pub trait EncryptInto { + /// Passes `()`: exists only for a `T` that needs no context from the caller. + fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> + where + C: EncryptTarget, + T: EncryptFrom<Self, C, ()> + 'a, + Self: Sized; + + /// Passes a `NonEmpty<N>`: anything that converts into one — `nonempty!("..")`, + /// `NonEmpty::new(value)?`, a bare integer. + fn encrypt_into_with_context<'a, T, C, N, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> + where + C: EncryptTarget, + T: EncryptFrom<Self, C, NonEmpty<N>> + 'a, + Ctx: Into<NonEmpty<N>>, + Self: Sized; +} +impl<S> EncryptInto for S { /* delegates to T::encrypt_from */ } +``` + +### Leaves are handwritten; composites are assembled + +**Leaves** are the single-primitive types. Each names exactly one primitive, and that is the *only* place in the design where a primitive is named. As shipped in `stack-encrypt`: + +```rust +// Every leaf, for `NonEmpty<T>` alone with `T: IntoAad<'c> + IntoPrfContext<'c>` — a leaf refuses `()` by type. +impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for StackCipherText where S: Encrypt + Clone { ... } +impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for EqualityTerm where S: PrfValue + Clone { ... } +impl<'c, S, K, O, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for MatchTerm<O> where S: AsRef<str>, O: MatchConfig { ... } +impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for OreTerm<S> where S: CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static { ... } +``` + +EQL's wire newtypes (`Ciphertext`, `Hmac256`, `OreBlock256`) get the same treatment in `eql-bindings`, which owns them; encoding decisions (base85, block width) belong there, not in vitaminc or stack-encrypt. + +> The leaf impls currently name `StackCipher<K>` rather than a capability bound. CIP-3897 tracks lifting each term into its own module with its own input trait and a capability-shaped cipher bound. + +**Composites** fan out to each field's impl, merge the outputs, and assemble. Today that is written by hand — one impl, in the shape the derive will eventually generate: + +```rust +impl<K, Ctx> EncryptFrom<u32, StackCipher<K>, Ctx> for EncryptedInt +where + Ctx: Clone, // fans out to every field + StackCipherText: EncryptFrom<u32, StackCipher<K>, Ctx>, // what each leaf demands, + EqualityTerm: EncryptFrom<u32, StackCipher<K>, Ctx>, // inherited, not restated + OreTerm<u32>: EncryptFrom<u32, StackCipher<K>, Ctx>, +{ + fn encrypt_from<'a>(source: &'a u32, cipher: &'a StackCipher<K>, context: Ctx) -> Pending<'a, Self, K> + where Self: 'a, + { + StackCipherText::encrypt_from(source, cipher, context.clone()) + .zip(EqualityTerm::encrypt_from(source, cipher, context.clone())) + .zip(OreTerm::<u32>::encrypt_from(source, cipher, context)) + .map(|((ciphertext, eq), ord)| Self { ciphertext, eq, ord }) + } +} +``` + +One context fans out to every field. `zip` concatenates the fields' requests, so the whole record is still one batched call when awaited. The where clauses are exactly the ones the derive writes — one `FieldTy: EncryptFrom<S, C, Ctx>` per field — so a record inherits its leaves' demand for a `NonEmpty<_>` context without naming it. (The derive emits this impl twice, for `Ctx = ()` and for `Ctx = NonEmpty<T>`; see "Context, not cipher scoping".) + +**The derive** writes exactly that impl from the struct: + +```rust +#[derive(EncryptFrom)] +#[stash(plaintext = i16, plaintext = i32, plaintext = i64)] +struct IntegerOrdOre { + #[stash(default = SchemaVersion::V3)] v: SchemaVersion, + c: Ciphertext, + ob: OreBlock256, +} +``` + +generating, per listed plaintext: + +```rust +impl<K, Ctx> EncryptFrom<i64, StackCipher<K>, Ctx> for IntegerOrdOre +where + Ciphertext: EncryptFrom<i64, StackCipher<K>, Ctx>, + OreBlock256: EncryptFrom<i64, StackCipher<K>, Ctx>, + Ctx: Clone, +{ /* join both, assemble */ } +``` + +The capability bounds (`C: Cipher`, `C: ProvidesOre`) arrive **transitively from the field impls**. The macro emits one `where` clause per derived field and names no primitive, no capability, and nothing from EQL. Adding a scheme is a new field type plus its leaf impl; the derive is untouched. + +### Which sources a target accepts + +`EncryptFrom<S, C, Ctx>` is generic over `S`; only the derive's `plaintext` attribute pins it. Two modes: + +- **Omit `plaintext`** — the derive emits a single impl generic over `S`. The accepted sources are then exactly the intersection of what the field types accept. Nothing to maintain. +- **List plaintexts** — one impl per listed type, restricting the target. + +Use the list for EQL types. `eql_v3_integer_ord_ore` is a schema statement that the column holds an integer, and `OreBlock256` is width-agnostic on the wire, so the generic form would accept a `String` and hand Postgres a payload it rejects. That restriction is EQL's, declared by EQL. The mechanism stays open: non-EQL targets omit `plaintext`. + +### Rows are the same mechanism + +One level up, unchanged — same trait, now written by the derive: + +```rust +#[derive(EncryptFrom)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + age: IntegerOrdOre, // from user.age, under "users/age" + email: TextEq, // from user.email, under "users/email" +} + +let row: EncryptedUser = user.encrypt_into(&cipher).await?; // one batch, no context: the fields carry theirs +``` + +`struct = User` infers each field's `from` (its own name) and the field half of +its context (`"<context>/<plaintext field>"`); the prefix is the required +container `context`, named explicitly — never inferred from the Rust type's +name, which two types can share and a refactor can change. `#[stash(from = ..)]` +and `#[stash(context = "..")]` on a field are the overrides, and +`#[stash(nested)]` marks a field whose type is itself a `struct` derive carrying its own +contexts (it is handed `()`). The context is the AAD of every stored +ciphertext in the column, so renaming a plaintext *field* is still a data +migration: pin the old literal with `context = ".."` first. + +Leaf, record and field-by-field struct are the same trait, and a column of any of them is `Vec<T>`'s structural impl over the same trait — `ages.encrypt_into_with_context(&cipher, ctx)` for a `Vec<u32>` is one batched call, and `users.encrypt_into(&cipher)` for a `Vec<User>` likewise. Recursion does the rest. Earlier sketches of this design had a separate input-side derive for rows — that was a second mechanism the naming was hiding. (The field-by-field form shipped as `row = ..` and was renamed `struct = ..` on 2026-09-04: "row" pushed database vocabulary into a general-purpose library. `plaintext = T` derives every field from the whole value whatever `T` is — the derive sees a name, not a definition — and `from` / `nested` exist only with `struct`.) + +### Relationship to `Encrypt` + +`Encrypt` is not bypassed or superseded. It **is** the source-ciphertext field. The `Ciphertext` leaf impl is a bridge: + +```rust +impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for StackCipherText +where S: Encrypt + Clone, T: IntoAad<'c>, +{ + fn encrypt_from<'a>(source: &'a S, cipher: &'a StackCipher<K>, context: NonEmpty<T>) -> Pending<'a, Self, K> + where Self: 'a, + { + let aad = context.into_aad().into_owned(); // the type is the proof; nothing to check + match source.clone().encrypt_with_aad(cipher, aad) { // vitaminc Encrypt, untouched + Ok(tree) => seal_pending(cipher, tree), // one data-key request per leaf + Err(_) => Pending::ready(cipher, Err(Error::Aead)), + } + } +} +``` + +(`Clone` because a composite hands the same borrowed source to several fields — open decision 1, resolved as the simple option.) + +Every existing impl — `String`, `u32`, `Vec<T>`, `HashMap<K, V>`, `Protected<T>`, `Option<T>`, `Element<T>` — is therefore a valid source for free, and `#[derive(Encrypt)]` (PR #287) is what makes a nested struct usable as one. + +Two layers, cleanly split: + +| | drives | produces | +|---|---|---| +| `Encrypt` / `Cipher` | the cipher | one ciphertext | +| `EncryptFrom` | the target type | a record of derived outputs, ciphertext being one field | + +## Capabilities + +A cipher advertises what it can do by implementing traits. `StackCipher` implements vitaminc's `Cipher` directly. It does **not** implement `Prf`: it *holds* a `vitaminc_hmac::HmacSha256Prf`, keyed by the keyset's index key at construction, and exposes it through `prf()`. Term impls read the accessor. + +**ORE is different, and vitaminc should not grow an ORE trait.** The scheme lives in its own crate and the cipher *holds* what it needs rather than implementing the scheme. + +The original proposal was `ore-rs` behind a `ProvidesOre` accessor on the cipher. What shipped is `cllw-ore`, and the key never surfaces at all: `OreTerm<T>` / `OpeTerm<T>` derive the per-field CLLW key through the PRF (from the field context, never the plaintext) *inside* the term's PRF visitor, encrypt there, and hand back only the ciphertext. Under the 2-party PRF backend that means per-field key derivation is an auditable ZeroKMS event and no key exists on either side to be leaked. + +The principle stands: capability accessors, not one god trait. The PRF is already held this way (`prf()`); any future primitive whose trait is owned elsewhere is absorbed the same way. + +## Context, not cipher scoping + +An EQL payload carries an identifier (`i`: table, column). Identifiers are an EQL concern and must not become cipher state. + +vitaminc already has the generic notion, twice — `Aad<'a>` (aead) and `PrfContext<'a>` (prf), both PAE-framed domain separators, neither aware of tables — and, since 0.2.0, the proof that a value carries caller bytes: `NonEmpty<T>`, checked once where the value is built (`nonempty!("users/email")` at compile time, `NonEmpty::new(value)?` at runtime, a bare integer for free). EQL's `Identifier` is just a value that converts into `Aad` and `PrfContext`, wrapped in `NonEmpty` on its way in. stack-encrypt adds no context trait of its own: anything vitaminc encodes as a context is a context here. (An earlier iteration had `EncryptContext` / `DecryptContext` aliases and a `SuppliedContext` marker — a hand-maintained roster of "every vitaminc context type but `()`" — which meant a type implementing vitaminc's traits was still not a context until stack-encrypt listed it. Gone.) + +`Clone` on the inner type, because one context fans out to every field of a record. + +Context is threaded **per value**, as an argument. It is not baked into the cipher. + +Whether the *caller* owes one is decided by the target type, at compile time. `Ctx` is a parameter of `EncryptFrom` so that each impl can bound it: a leaf is implemented for `NonEmpty<T>` alone, because it has nothing else to authenticate under and `()` is the empty context it must never derive under; a derived record is implemented twice — for `()`, deriving each field under the context it carries itself (a `context = ".."` literal, or the one a `struct = ..` derive infers), and for `NonEmpty<T>`, deriving each field under that context *extended* with the caller's (`("users/age", id)`), or under the caller's as it is for a field with none. No record accepts a context and then discards it. `encrypt_into(&cipher)` passes `()` and therefore compiles only for outputs whose every leaf has a context of its own; everything else takes `encrypt_into_with_context`, and such an output takes that too when the caller has something to add, a record id say, so a field is bound to its record as well as its name. This is vitaminc's `encrypt` / `encrypt_with_aad` split, with the choice made by the type rather than at every call site. + +A scoped cipher (`cipher.for_column("users", "age")`) was considered and rejected: it makes encrypting one record — several fields, several identifiers — into several scoped ciphers, which fights batching for no gain. With context as an argument, a record is one shared `&cipher`, many contexts, one flush. + +### Recommendation: bind the identifier into the AAD + +EQL's `i` field is currently unauthenticated metadata. A ciphertext from `users.email` can be transplanted into `users.name` and still decrypts. Passing the identifier as context — which reaches both `Aad` and `PrfContext` — closes that class of attack. + +This stays a caller decision at the call site, not cipher state: non-EQL callers pass whatever context describes the field. What they may **not** pass is an empty one. With an empty context, equal plaintexts in different fields produce identical terms, every field shares one ORE/OPE key, and ciphertexts transplant between fields — so a leaf takes a `NonEmpty<T>` and nothing else, and an empty one cannot be built: `""`, `None`, `Some("")` and `("", "")` are all refused by `NonEmpty::new` (vitaminc's structural `MaybeEmpty`, on the raw value), and `()` is a compile error. There is no runtime path through a leaf for an empty context to fail on. + +### The context is the ZeroKMS descriptor + +The same context goes to ZeroKMS with every data-key request the leaf makes, as the request's `descriptor` — the field the legacy `cipherstash-client` used for exactly this. ZeroKMS HMACs the descriptor into the key tag and re-derives a key only under the descriptor it was generated with, and the descriptor is what its retrieval log records per key. So the binding the AAD makes locally is also enforced server-side, before any key material moves, and the field name is readable in the audit trail. `Descriptor::from_piece` is the one, frozen rendering of a context's parts (vitaminc 0.3.0's `AadPiece` tree) as that string: textual parts verbatim, integers by their width and sign-blind (`7i64` renders `7u64`, as it encodes), a composite's parts joined by `|` — `nonempty!("users/email").with(7u64)` is `users/email|7u64` — and text that could read as another form (a leading digit, a `|`, a `b64:` prefix, or nothing at all) escaped as `b64:` plus base64, so the rendering is injective over encodings. It follows the context's parts rather than its bytes, so it is finer than the encoding for a pre-encoded `Aad` (one opaque bytes part) and for shapes that encode alike: a value is opened under the context in the same shape it was sealed under. With ZeroKMS enforcing the descriptor, a wrong context is refused at key retrieval (`Error::Kms`) before the AEAD runs; only a key source that ignores descriptors, such as the test fake, reaches the AEAD's `Error::Aead`. The lock-context tags and decryption policies ZeroKMS also offers are a newer channel, not yet stable enough to build on; `docs/context-model.md` in the coderdan/0kms repository explores what the next ZeroKMS should offer instead of one string. + +## Batching and async + +Awaiting at the leaf is one round-trip per value *unless* `Pending` is a deferred handle on a shared batch that flushes on first await. That is the whole reason the cipher implements `Cipher` and `Prf` together: one object, one keyset, one batch covering both the source ciphertext and every ZeroKMS-derived term in the record. + +The `struct = ..` derive above is the entry point that makes this pay: one `.await` for a whole struct rather than one per field. + +## Analysis vs derivation + +Worth preserving from the spike: `ExactIndex::analyze() -> AnalyzedExactIndex`. + +Splitting **analysis** (tokenise, normalise, extract n-grams — pure, sync, keyless) from **derivation** (keyed, possibly async) is right, and text-match indexes cannot skip it. Under this design, analysis is a private stage inside a leaf impl (`MatchTerm<O>` tokenises before it derives; the tokenizer, `k` and `m` are type-level via `MatchConfig`), exposed as a public trait only if a custom analyser is needed. + +## Naming + +- **`EncryptFrom`** for the trait (first shipped as `EncryptedFrom`, renamed in review). Spelling the direction keeps bounds unambiguous, and it pairs with `encrypt_into` exactly as `From` pairs with `Into`; `DecryptInto` / `decrypt_from` mirror it. +- **`encrypt_into` / `encrypt_into_with_context`** for the two forms of the sugar, after vitaminc's `encrypt` / `encrypt_with_aad`; `_with_context` rather than `_with_aad` because here the value feeds the PRF domain separation as well as the AAD. +- **`#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`** for the macros, each named after the trait it emits, the way `Serialize` matches `derive(Serialize)`. `#[derive(Encrypted)]` — a noun on the struct — was the sketch; it names neither trait, and one noun cannot cover both directions. +- **`#[stash(..)]`** for the attribute, after the crate rather than after either derive, since both derives read the same annotations. +- **Avoid `CipherText` / `EncryptedValue`.** `CipherText` collides with vitaminc's `AesCipherText` container and with eql-bindings' `Ciphertext` newtype — which is a *field inside* these types, not the type itself. + +An earlier iteration had two traits, `EncryptInto<T, C>` on the source and `DeriveFrom<S, C>` on the field type. They are the same relation written in opposite directions; the split was the main source of confusion and is gone. + +## Decisions, as resolved + +**1. Ownership at the bridge.** `Encrypt::encrypt_with_aad(self, ...)` takes ownership; `encrypt_from(source: &S, ...)` borrows, because k fields share one source. The `Ciphertext` bridge above does not compile as written. Options: + +1. `S: Encrypt + Clone` on the bridge. Simplest. Costs k copies of the plaintext. +2. Blanket `impl<T> Encrypt for &T where T: Encrypt`. vitaminc already has `impl Encrypt for &str`, so the shape exists but is not systematic. +3. Derive hands ownership to the ciphertext field and borrows to the term fields. Cheapest; puts field-ordering knowledge into the macro. + +**Resolved:** (1). `S: Encrypt + Clone` on the bridge; (2) later if the copies show up in a profile. + +**2. Fan-out and zeroize.** **Resolved, and narrower than proposed.** The source reaches k consumers, but only the ciphertext's copy lives in `Protected` — it is held inside the pending and wiped as it seals. Term clones are ordinary values consumed during the synchronous build and dropped before any I/O; they are not wrapped. So the custody widening is bounded to the build phase for terms and to the pending's lifetime for the ciphertext. Stated in the `stack_encrypt::target` rustdoc ("Plaintext fan-out"), as this section asked. + +**3. Orphan rule.** `impl<C> EncryptFrom<i64, C> for IntegerOrdOre` in eql-bindings is legal — `Self` is local. The reverse-direction sugar (`EncryptInto::encrypt_into` on `i64`) is a blanket impl over a local trait, also fine. Worth a compile test pinning both, since the layout puts the trait, the source type and the target type in three different crates. + +**4. Error unification.** **Dissolved.** There is no per-target error: `EncryptTarget::Error` belongs to the cipher, and `StackCipher`'s `Error` already covers AEAD, PRF, ORE and ZeroKMS failures. + +**5. Decrypt.** **Implemented** as `DecryptInto<P, C: DecryptTarget, Ctx>` on the encrypted type (first shipped as `DecryptFrom` on the plaintext; flipped so the implementable trait has the record as `Self`), with `DecryptFrom::decrypt_from` / `decrypt_from_with_context` as the blanket sugar on the plaintext. Only the source-ciphertext field participates (terms are one-way). The leaves take `NonEmpty<T>` with `T: IntoAad` only, since decryption derives nothing; a derived record opened under one extends its fields' contexts with it, exactly as it did when encrypting. + +**6. Where `EncryptFrom` lives.** **Resolved:** stack-encrypt. Argued here as stack-encrypt's, since target-directed assembly is the thing stack-encrypt adds and vitaminc's `Encrypt` already covers cipher-directed encryption. If it turns out to be useful to vitaminc consumers who never touch stack-encrypt, it could move down — but not before there is a second consumer. + +## Non-goals + +- EQL knowledge anywhere in vitaminc or in the derive macro. +- Replacing `Encrypt` / `Cipher`. This layer sits on top of them. +- Runtime-configured index sets. protect.js takes the index set from a runtime schema; in Rust with sqlx the target type is known at compile time, and this design spends that fact rather than reproducing the dynamic model. diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md new file mode 100644 index 000000000..11cf10afb --- /dev/null +++ b/docs/wasm-analysis.md @@ -0,0 +1,213 @@ +# WASM / Supabase Edge support for protect-ffi + cipherstash-client + +## Goal + +Run encrypt/decrypt against ZeroKMS from a Supabase Edge Function (Deno runtime, single-threaded, all outbound HTTP through `fetch`, ~10MB deployable budget). + +## Layered plan + +Originally scoped as four layers. After looking more carefully at cipherstash-client, the dominant blockers for what was Layer 2 (`tokio::spawn` background refresh, `std::fs` token caches, `dirs`/`open` interactive auth) all live inside legacy credentials code that is **already unused** in current cipherstash-client — kept alive only because proxy is pinned to a pre-migration version. Promoting that cleanup to its own layer ahead of the wasm-specific work shrinks the remaining work substantially. + +Five layers, Layer 1 already shipped: + +### Layer 1 — Pure crypto/protocol crates [DONE] + +Six crates compile clean for `wasm32-unknown-unknown`: `recipher`, `cipherstash-core`, `cipherstash-config`, `cllw-ore` (`--no-default-features`), `cts-common` (`--no-default-features`), `zerokms-protocol`. See "Layer 1 — what shipped" below. + +`vitaminc-encrypt` got a cfg-based dual backend (aws-lc-rs on native, RustCrypto on wasm32) so cipherstash-suite can use AEAD types from `vitaminc::encrypt` on both targets without a feature-gate workaround. Available from vitaminc 0.2.0-pre on crates.io (vitaminc PR #163, shipped as part of the 0.2.0-pre minor-bump release). + +### Layer 2 — Legacy credentials cleanup [DONE] + +Shipped in PR #1943. cipherstash-client 0.34 already migrated every consumer-facing type to the bound `for<'a> &'a C: AuthStrategy` from `stack-auth`; the whole legacy `Credentials` / `AutoRefreshable` tree was dead, kept alive only because proxy is pinned to `cipherstash-client = "0.32.2"`. Deleted modules: `credentials/{auto_refresh, user_credentials, service_credentials, static_credentials, token_store}`, the `Credentials`/`AutoRefreshable`/`TokenExpiry` traits, the never-imported `logger_client`/`reqwest_client` modules, and the `sleep` wrapper. -2042 lines. Dropped `open` and `cfg-if` deps. + +`ServiceToken` (the legacy JSON-wire type at `cipherstash_client::credentials::service_credentials::service_token::ServiceToken`) is intentionally retained — it backs a `serde::Deserialize`able `{accessToken, expiry}` contract that protect-ffi consumes, and migrating it requires a separate design decision. + +The cipherstash-client release + proxy bump (separate concern, not blocking wasm work) remains as a follow-up. + +### Layer 3 — `stack-auth` + `cipherstash-client` wasm32 compile [DONE] + +Shipped in PR #1944. Both crates now build for `wasm32-unknown-unknown`. The wasm-compatible auth surface is `AccessKeyStrategy` (full M2M flow with token caching), `OAuthStrategy::with_token` (caller-supplied JWT with in-memory refresh) — the path edge workers will use — and `ZeroKMS<S>::encrypt`/`decrypt` against any wasm-compatible strategy. + +Deliberately not on wasm: `device_code` flow (uses `open::that`), `stack-profile` (filesystem), `cts_client`, `management`, `config::source`, `config::paths`, `config::docker_env_file`. Per-target dep splits in `stack-auth` and `cipherstash-client` work around workspace `tokio = { features = ["full"] }` (pulls `mio`, which doesn't compile to wasm32) by giving each affected crate a target-conditional minimal tokio. + +### Layer 3.5 — `@cipherstash/auth` becomes wasm-capable [IN PROGRESS] + +Two PRs. + +**PR #1952 — `stack-auth-wasm` bindings crate [MERGED].** Adds `packages/stack-auth/wasm` as a sibling to the existing napi crate. Scoped to `AccessKeyStrategy` (M2M auth) — `getToken(): Promise<TokenResult>` returning `{ token, subject, workspaceId, issuer, services }`. Errors carry a `.code` enum matching the napi contract. OAuth-based strategies (`OAuthStrategy`, `AutoStrategy`, device-code) are deferred to a follow-up: federation and token-pinning for browser/edge contexts need design work that hasn't happened yet. + +Error-code mapping is hoisted onto `AuthError::error_code()` in the parent `stack-auth` crate so this PR and the existing napi sibling can share one source of truth (napi adoption is a non-functional cleanup for a follow-up). + +Build targets: `wasm-pack build --target bundler` (primary — Supabase Edge, Vite, Webpack) and `--target deno` (vanilla `deno run` only — Supabase Edge Runtime sandbox blocks `fetch('file://…')` so the deno target's auto-fetch of its `.wasm` sibling fails there; the bundler output uses `import * as wasm from "./*.wasm"` which the Edge Runtime resolves natively). Tests run via `wasm-pack test --node` — pure-logic coverage (JWT claim extraction, error-code mapping, constructor smoke). HTTP semantics stay covered by the existing native `stack-auth/node/__tests__` vitest suite. CI gains the wasm32 cargo-check + wasm-pack test step alongside the existing nextest step in `test-stack-auth.yml`. + +End-to-end validated against a live Supabase Edge Function returning a real `TokenResult` from `AccessKeyStrategy.getToken()` against `ap-southeast-2.aws`. The validation surfaced three runtime issues fixed in #1952: + +- `stack-auth` called `std::time::SystemTime::now()` in `token.rs` and `access_key_refresher.rs` for JWT-expiry checks. The stdlib's `wasm32-unknown-unknown` `time` module is a panicking stub. Swapped to `web_time::{SystemTime, UNIX_EPOCH}` (re-exports `std::time` on native, polyfills via JS time APIs on wasm — no behavior change off wasm). +- Rust panics on wasm surface as opaque `RuntimeError: unreachable` from bytecode offsets. Added `console_error_panic_hook` and route panics to `console.error` via a `#[wasm_bindgen(start)]` module-init function. +- `wasm-pack --target deno` doesn't work in the Supabase Edge Runtime — its sandbox blocks `fetch('file://…')`, which is how the deno target loads its sibling `.wasm`. Made `--target bundler` the primary build (uses `import * as wasm from "./*.wasm"`, which Edge resolves natively); deno target retained for vanilla `deno run`. + +**PR #1953 — npm unification.** Stacks on #1952. Single `@cipherstash/auth` npm package serves Node, browser/bundler, and edge consumers from one install. Final `exports` shape (post-PRs #1958 + #1959): + +| Entry | `node` condition | `default` condition | +|---|---|---| +| `.` (main) | `./index.js` (napi loader) + `./index.d.ts` | `./wasm/stack_auth_wasm.js` (bundler-target, sibling `.wasm`) + `./wasm-types.d.ts` | +| `./wasm` | — | `./wasm/stack_auth_wasm.js` (raw bundler-target shim; lower-level surface) | +| `./wasm-inline` | — | `./wasm-inline.mjs` (slick wrapper with options-object API + inline-bytes wasm via `./wasm/stack_auth_wasm_inline.js`) | +| `./cookies` | — | `./cookies.mjs` (pure-JS `cookieStore` helper for WHATWG-fetch runtimes) | + +Consumer routing: +- **Node** — bare `@cipherstash/auth`, gets full napi surface (device-code, profile-store, OAuth, AccessKeyStrategy). +- **Vite / Webpack / Next.js bundler users** — bare `@cipherstash/auth`, the bundler handles the sibling `.wasm` import as an asset chunk. +- **Supabase Edge Functions, Cloudflare Workers, Bun / Deno via `npm:`** — explicit `@cipherstash/auth/wasm-inline`. Loads the base64-inlined wasm shim with zero runtime config (no `static_files`, no asset copying, no bundler plugins). + +Why Edge consumers need the explicit sub-path: validating against a live Supabase Edge worker surfaced two fundamental Supabase Edge Runtime 1.73.0 constraints, plus a conditional-exports limitation that affects every Deno-resolving-`npm:` runtime: + +- **Bare `.wasm` ESM imports aren't supported, and assets aren't auto-bundled.** Native `import * as wasm from "./x.wasm"` (Deno 2.x), `import bytes from "./x.wasm" with { type: "bytes" }` (modern web import attributes), and `Deno.readFile` from inside `node_modules` all fail in Edge 1.73.0 unless the `.wasm` is declared in `supabase/config.toml` via `static_files`. The inline-bytes shim base64-encodes the wasm into the JS module so no asset bundling is required — works everywhere `WebAssembly.instantiate` works. +- **No condition distinguishes Deno-via-`npm:` from Node ESM.** Deno applies `[node, import, default]` for `npm:` specifiers — the same set Node ESM applies. There's no condition we can place in the exports map that fires for Deno-via-`npm:` but not Node, so we can't route the bare `.` import to wasm-inline for Edge while keeping napi for Node ESM. Tried it (alpha.2 default-flip); Edge still hits the `node` branch first and tries to load the CJS napi loader, which has no statically-resolvable ESM named exports and fails at boot. The `./wasm-inline` sub-path bypasses the conditional walk entirely. +- **Deno's `deno`/`worker`/`browser` conditions don't fire for `npm:` specifiers.** Same root cause — for npm-distributed packages, Deno walks `[node, import, default]` only. These keys are dead weight in an `npm:` package's exports map. + +Trade-off for inline: ~27% larger JS payload (~726KB vs ~572KB sibling `.js`+`.wasm`, post-`wasm-opt -Oz`) and ~50ms cold-start vs streaming compile. Acceptable for an auth surface that runs once per worker boot, not per request. + +Other validation-driven fixes folded into the PR: + +- `serde_wasm_bindgen::Serializer::json_compatible()` for the `TokenResultPayload` so `services: BTreeMap<String, String>` serialises as a plain JS object — `BTreeMap` defaults to JS `Map`, which `JSON.stringify` flattens to `"{}"`, dropping every entry. The `wasm-types.d.ts` overlay declares `services: Record<string, string>`, so this aligns runtime shape with declared type. +- `wasm-types.d.ts` is committed hand-written (refines `Promise<any>` → `Promise<TokenResult>`, hides wasm-streams type leakage from reqwest's fetch backend, scoped to `AccessKeyStrategy`). +- CI (`publish-auth-npm.yml`) gains a `build-wasm` job that runs wasm-pack + the inline-bytes postbuild script (`scripts/inline-wasm.mjs`); the `publish` job depends on it so every release ships the inline shim. Prerelease pipeline validated through `0.37.0-alpha.0` (bundler-target only) → `0.37.0-alpha.1` (inline added) → `0.37.0-alpha.2` (services serialization fix; also tested a default-entry flip that turned out not to help Edge consumers) → `0.37.0-alpha.3` (default-flip reverted, docs corrected) → `0.37.0-alpha.5` (TokenStore trait + wasm `createWithStore` bindings landed via PRs #1958 + #1959) → `0.37.0-alpha.6` (slick options-object API + built-in `cookieStore` helper; spike's integration code dropped to ~35 lines, three of which are stack-auth-related). All published under the `next` dist-tag. + +Rationale for this layer: protect-wasm (Layer 4) will need to wrap auth strategies anyway. Establishing the wasm-bindgen toolchain, inline-bytes postbuild pattern, and the exports-map shape here on a small crate means Layer 4 doesn't absorb both the toolchain bootstrap and the encrypt/decrypt porting in the same PR. + +### Layer 4 — Wasm bindings for the encrypt surface + +> **Likely superseded** — see "Medium-term direction" below. Skipping straight to Layer 5-via-stack-encrypt is on the table. + +Original scope: add a sibling `protect-wasm` crate in the `protectjs-ffi` repo (next to the existing `crates/protect-ffi`) using `wasm-bindgen` + `wasm-bindgen-futures` + `serde-wasm-bindgen`. Port the 9 `#[neon::export]` async functions (`new_client`, `ensure_keyset`, `encrypt`, `encrypt_bulk`, `encrypt_query`, `encrypt_query_bulk`, `decrypt`, `decrypt_bulk`, `decrypt_bulk_fallible`). Build with `wasm-pack --target bundler`. Then unify under `@cipherstash/protect-ffi` using the same conditional-exports pattern Layer 3.5 establishes. + +Prereqs that don't apply to stack-auth's case: + +- Bump `protectjs-ffi` from `cipherstash-client = "=0.34.1-alpha.2"` / `vitaminc = "=0.1.0-pre4.2"` to the post-Layer-3 versions (cipherstash-client 0.34.1-alpha.4+, vitaminc 0.2.0-pre+). Expect API drift to fix. +- Gate `stack-profile` use in `new_client` / `ensure_keyset` — wasm has no filesystem. Pattern: accept the client key inline as a parameter (mirroring how `OAuthStrategy.withToken` replaces `fromProfile`). +- Target-split `tokio = "full"` (pulls `mio`, doesn't compile to wasm32) — same workaround stack-auth/cipherstash-client got in PR #1944. + +Estimated wasm bundle: 1.5–2.5MB unoptimised, ~800KB–1.2MB optimised. Well under the 10MB Supabase Edge cap. + +### Layer 5 — Validation in a Supabase Edge Function + +Deploy a real edge function that calls `protect-wasm`, encrypt/decrypt against ZeroKMS, measure: + +- Bundle size vs the 10MB cap +- Cold-start latency +- Round-trip correctness against a server-side native client +- Cross-backend ciphertext compatibility — encrypt on wasm (RustCrypto), decrypt on native (aws-lc-rs), and vice versa. This is the cross-backend compat test deferred from earlier. + +### Layer 6 — WASI / wazero for the Go SDK [IN PROGRESS] + +Everything above targets `wasm32-unknown-unknown` for a **JavaScript host** (Supabase Edge, browsers, Deno): outbound HTTP rides the host's `fetch`, and reqwest's wasm backend, `getrandom`'s `wasm_js` backend, and `web_time` all lean on JS APIs the host provides. + +The Go Encryption SDK (`goencryption`, formerly `protectgo`) has a different motivation and a different target. It ships six per-platform C static libraries linked via cgo, which forces `CGO_ENABLED=1`, a C toolchain, and a build/commit matrix per OS/arch. Compiling the client to wasm and running it under a pure-Go WebAssembly runtime — **wazero** — removes cgo entirely: one `.wasm` in the module, `CGO_ENABLED=0`, and ordinary `GOOS/GOARCH` cross-compilation. + +But wazero is **not** a JS host. It targets `wasm32-wasip1` (WASI preview 1) — `target_os = "wasi"`, not `"unknown"` — and provides no `fetch`, no `web-sys`, no wasm-bindgen imports. So the JS-oriented Layers 1–5 do not transfer as-is; this is a distinct target with a distinct blocker. + +**History.** PR #2099 was the beachhead: it proved the `ZeroKMSConnection` seam could be satisfied by a single host-imported function (`cipherstash_transport::transport_send`, backed by Go's `net/http`) with request assembly, error mapping, chunked concurrency and client-side key derivation all running unmodified inside the guest, and validated it end to end against a real ZeroKMS. It was written against `cipherstash-client`, which `stack-kms` / `stack-encrypt` replace, so it is not merged as-is; the reusable pieces are re-targeted by the plan below. + +**The measured blocker (2026-08).** `cargo check --target wasm32-wasip1 -p stack-encrypt --no-default-features` fails on exactly two things — tokio (`Only features sync,macros,io-util,rt,time are supported on wasm`) and the `aws-lc-sys` build script — and both are pulled solely by `reqwest 0.13.4` via `stack-auth` and `stack-kms`. On wasip1 reqwest ≥ 0.13.4 selects its *native* backend (hyper / tokio-full / hickory / rustls / aws-lc-sys), where 0.13.2 selected the fetch/wasm-bindgen backend #2099 fought. There is **no** `wasm-bindgen` / `web-sys` / `js-sys` in the wasip1 tree any more. The `aws-lc-sys` failure is rustls's TLS provider inside reqwest, not the AEAD — `vitaminc-encrypt` already selects its pure-Rust `aes-gcm` backend on `cfg(target_arch = "wasm32")`, which covers wasip1. Only the network stack is missing, and it has to be out of the WASI build *by construction*, not by dead-code elimination or version pinning. + +**Architecture: host-provided transport.** HTTP stays out of the wasm and is satisfied by a function the Go host provides; control stays in Rust (the "host orchestrates each step" shape was considered and rejected in #2099 because it smears the protocol state machine across the FFI). Why not HTTP inside the guest: wasip1 has no `sock_connect` (receive/accept only), so outbound TCP needs a host import regardless; TLS in the guest would mean rustls on a pure-Rust provider with embedded roots and no AES-NI, strictly worse than Go's `crypto/tls` with system roots; and `wasi:http` — the right long-term answer — is component model, which wazero does not run. The host can already read guest memory, so routing HTTP through it weakens nothing: what crosses the boundary is exactly what crosses TLS (URL, bearer token, protocol JSON). Data keys, the client key and the index key never do. + +**The plan** lives in [`docs/plans/stack-encrypt-go-bindings.md`](plans/stack-encrypt-go-bindings.md): phases, the vitaminc `bindings/go` layering (`vcvalue` value model + FFI codec are reused; the stack-encrypt side is the cipher/KMS side), the frozen byte formats stack-encrypt owns, and the open decisions. Terminology fixed there: *storage format* (sealed leaf, into a database), *FFI codec* (host ↔ guest marshalling, throwaway), *transport* (HTTP, out of the process). + +**Gate.** `mise run wasm:wasi-check` compiles the HTTP-free core for `wasm32-wasip1` and fails if any crate's normal-dependency tree contains a JS-host backend (`wasm-bindgen`/`web-sys`/`js-sys`) **or** the native HTTP/TLS stack (`reqwest`/`hyper`/`aws-lc-sys`). Phase 0 gates `zerokms-protocol`, `cipherstash-core`, `recipher`, `cts-common`, `cllw-ore`; Phase 1 adds `stack-auth`, `stack-kms`, `stack-encrypt` once reqwest is behind a feature in each. + +## Medium-term direction — `stack-encrypt` replaces `cipherstash-client` + +Layer 4 as scoped above ports the existing `protect-ffi` neon bindings to wasm. That works, but it's strictly a tactical move — the underlying `cipherstash-client` crate is the long-pole heavy dependency (full reqwest stack, EQL types, config sources, etc.), and `protect-ffi` is a thin async wrapper over it. + +The cleaner long-term shape mirrors what we just did with auth: + +1. **`stack-encrypt`** — a new slim crate inside cipherstash-suite, in the spirit of `stack-auth`. Pulls only what's needed for encrypt/decrypt/query against ZeroKMS. Drops the config sources, the EQL type machinery, the device-identity persistence. Backed by `stack-auth` for the credential half, by `vitaminc-encrypt` for crypto, and a minimal HTTP client for the ZeroKMS protocol calls. + +2. **`stack-encrypt/node` (napi)** — replaces today's `protectjs-ffi` neon bindings. Single-crate-per-binding pattern is consistent with `stack-auth/node`. + +3. **`stack-encrypt/wasm` (wasm-bindgen)** — replaces what Layer 4 would have been. + +4. **Single `@cipherstash/protect` npm package** under the same conditional-exports pattern Layer 3.5 establishes. + +This is a meaningfully larger piece of work than Layer 4. It involves designing the slim public API of `stack-encrypt`, porting the protect-ffi semantics, migrating downstream consumers (Drizzle / Prisma / TS-ORM integrations that currently consume `@cipherstash/protect-ffi`). It's not on the critical path for Layer 5 — a working `protect-wasm` (Layer 4 as originally scoped) can prove out Supabase Edge first, and `stack-encrypt` follows on a longer arc. + +The decision point is: **does Layer 5 need to ship sooner, or do we wait and skip Layer 4 entirely?** + +- *Layer 4 first*: faster path to a live Supabase Edge demo (weeks). Builds throwaway-ish bindings on top of `cipherstash-client`. Need to keep them maintained until `stack-encrypt` lands. +- *Skip to stack-encrypt*: cleaner, but Layer 5 slips by however long `stack-encrypt` takes (months). Less duplication of binding work. + +Pending decision. The rest of this doc assumes Layer 4 happens for now, but every section below `## Status` should be read as conditional. + +## Supabase Edge runtime specifics + +- Deno-based, supports `WebAssembly.instantiate` +- Single-threaded — no `tokio::spawn` across threads, no `rayon` +- Outbound HTTP only via host `fetch` (reqwest's wasm backend uses this transparently) +- No filesystem, no env beyond what the function declares +- Bundle size cap ~10MB. Crypto + reqwest + serde stack will land ~1.5–3MB stripped +- Cold start: each invocation may be a fresh instance — token caching is in-memory and short-lived + +## Status + +- [x] Analysis (this doc) +- [x] Layer 1 — pure crates verified on wasm32 (PR #1942) +- [x] Layer 2 — legacy credentials cleanup (PR #1943) +- [x] Layer 3 — `stack-auth` + `cipherstash-client` compile on wasm32 (PR #1944) +- [~] Layer 3.5 — `stack-auth-wasm` bindings crate (#1952) + npm unification (stacked follow-up) +- [ ] Layer 4 — wasm bindings for encrypt — **likely superseded by stack-encrypt; pending decision** +- [ ] Layer 5 — Supabase Edge validation +- [~] Layer 6 — WASI / wazero for the Go SDK: Phase 0 (gate + plan) landed; Phase 1 (`stack-auth`/`stack-kms`/`stack-encrypt` build for wasip1 without reqwest) stacked on it. Plan: `docs/plans/stack-encrypt-go-bindings.md` + +## Layer 1 — what shipped + +Verified on `cargo check --target wasm32-unknown-unknown`: + +| Crate | Invocation | +|---|---| +| `recipher` | `cargo check --target wasm32-unknown-unknown -p recipher` | +| `cipherstash-core` | `cargo check --target wasm32-unknown-unknown -p cipherstash-core` | +| `cipherstash-config` | `cargo check --target wasm32-unknown-unknown -p cipherstash-config` | +| `cllw-ore` | `cargo check --target wasm32-unknown-unknown -p cllw-ore --no-default-features` (postgres-types is server-only) | +| `cts-common` | `cargo check --target wasm32-unknown-unknown -p cts-common --no-default-features` | +| `zerokms-protocol` | `cargo check --target wasm32-unknown-unknown -p zerokms-protocol` | + +Changes: + +- `.cargo/config.toml` — wasm32 rustflag `--cfg getrandom_backend="wasm_js"` (required by `getrandom >= 0.3` to select the browser/Deno backend; pulled in via `vitaminc-random` → `rand 0.10`) +- Per-crate wasm32 target deps for `getrandom` (`js` feature for v0.2, `wasm_js` for v0.4) so feature unification activates the right backend +- `cts-common` wasm32 target dep on `uuid = { features = ["js"] }` so `Uuid::new_v4()` can source entropy +- Workspace `vitaminc`, `vitaminc-aead`, and `vitaminc-protected` on crates.io (`0.2.0-pre` at the time; now `0.3.0`) — the first release containing the cfg-based dual backend (aws-lc-rs on native, RustCrypto on wasm32) for `vitaminc-encrypt`. Shipped via [vitaminc PR #163](https://github.com/cipherstash/vitaminc/pull/163). + +Native build verified via `mise run lint`. `cts-common` unit tests: 138/138 passing. + +## Layer 2a — what shipped + +Deleted from cipherstash-client (PR #1943): + +- `credentials/auto_refresh.rs` +- `credentials/user_credentials/` (auth0, okta, user_token, mod) +- `credentials/service_credentials/{service_user_credentials, service_access_key_credentials}.rs` +- `credentials/static_credentials.rs`, `credentials/token_store.rs` +- `credentials::{Credentials, AutoRefreshable, TokenExpiry}` traits and associated error types +- `logger_client.rs`, `reqwest_client.rs`, `sleep.rs` + +Cargo.toml: dropped `open` and `cfg-if` deps. Kept `tokio` feature flag (still used by `encryption/builder/mod.rs` and `eql`). + +Net delta: 19 files changed, +14 / -2042. Verified via `cargo check --workspace --tests` (no `--all-features`, to avoid the lint-trap class), `mise run lint`, and 340 cipherstash-client unit tests passing. + +`cipherstash_client::credentials::ServiceToken` is the only piece of the credentials tree that survives, retained for its JSON wire contract with protect-ffi. + +## Known follow-ups + +- Layer 1's `cargo check` verification is now partially exercised by Layer 3.5's `wasm-pack build` (pulls `cts-common`, `cipherstash-config`, `zerokms-protocol` transitively). Crates outside that dep graph (`recipher`, `cipherstash-core`, `cllw-ore`) still need a real artifact build. +- `cllw-ore` requires `--no-default-features` because the default `postgres-types` feature has C deps. Consider flipping the default off in a future major version (already noted in its Cargo.toml). +- Cipherstash-client 0.35 release containing the legacy delete; proxy bump to 0.35 with `AccessKeyStrategy` migration. See Layer 2c. +- `ServiceToken` JSON contract migration (Layer 2b) — design conversation needed before code. +- Pre-existing API drift between cipherstash-client and protect-ffi (path dep) — `cipherstash_client::eql::EncryptedField` not found. Surfaces under `cargo check -p protect-ffi`. Not caused by Layer 2a; flagged for the next protect-ffi sync. +- Adopt `AuthError::error_code()` in `stack-auth-node` (the napi sibling) — currently inlined there, now duplicates the parent crate. +- OAuth-based wasm strategies (`OAuthStrategy`, `AutoStrategy`, device-code) — deferred from Layer 3.5 pending federation/token-pinning design. +- Token cookie pinning — encrypt the JWT under a worker-only key before storing in cookies (so a stolen cookie can't be replayed elsewhere). +- Never-expose-JWT API — wallet/keychain pattern where the JWT lives only in wasm memory and JS calls signed operations. +- npm publishing strategy — separate `@cipherstash/stack-auth-wasm` package vs sub-path under existing `@cipherstash/auth` vs conditional exports. diff --git a/languages/golang/.golangci.yaml b/languages/golang/.golangci.yaml new file mode 100644 index 000000000..00b010b5e --- /dev/null +++ b/languages/golang/.golangci.yaml @@ -0,0 +1,27 @@ +version: "2" + +linters: + default: none + enable: + - bodyclose + - errcheck + - gosec + - govet + - ineffassign + - staticcheck + - unused + exclusions: + presets: + # Unchecked Close, Flush, fmt.Fprint* and friends. + - std-error-handling + rules: + # Test fixtures, weak rand and subprocesses are fine in tests; + # guesttest is imported only by _test files. + - path: (_test\.go|internal/guesttest/) + linters: + - gosec + +formatters: + enable: + - gofmt + - goimports diff --git a/languages/golang/go.mod b/languages/golang/go.mod new file mode 100644 index 000000000..bbb768fa2 --- /dev/null +++ b/languages/golang/go.mod @@ -0,0 +1,12 @@ +module github.com/cipherstash/stack/languages/golang + +go 1.25.0 + +require ( + github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20260902024806-f2c7f7d17fbc + github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20260902024806-f2c7f7d17fbc + github.com/tetratelabs/wazero v1.12.0 + golang.org/x/oauth2 v0.36.0 +) + +require golang.org/x/sys v0.44.0 diff --git a/languages/golang/go.sum b/languages/golang/go.sum new file mode 100644 index 000000000..75eda4d1c --- /dev/null +++ b/languages/golang/go.sum @@ -0,0 +1,10 @@ +github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20260902024806-f2c7f7d17fbc h1:iGbTgv9Kx5SaTI2tPxaxwDvS5BXZUO565N00cxRVwC8= +github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20260902024806-f2c7f7d17fbc/go.mod h1:6jpaqAo6f7rjWuxoti0Ymoi9DQZyxpnieWVudhXv38Y= +github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20260902024806-f2c7f7d17fbc h1:vlrjoILAURGfpBWucVZEywK22k4lfky1xGDIKV6kqNg= +github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20260902024806-f2c7f7d17fbc/go.mod h1:RJODA1DCSm4H+1pbmCzDdT2pAIkxhinwIu6ewjh5sPs= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/languages/golang/internal/factstest/factstest.go b/languages/golang/internal/factstest/factstest.go new file mode 100644 index 000000000..38c26e46e --- /dev/null +++ b/languages/golang/internal/factstest/factstest.go @@ -0,0 +1,166 @@ +// Package factstest is a test-only fact source for the plan package: Go +// struct fields annotated with a `facts` tag. The tag syntax is not API; +// real facts come from a schema, such as the protobuf source planned in +// CIP-4088. Internal, and imported only by _test files. +package factstest + +import ( + "errors" + "fmt" + "reflect" + "slices" + "strings" + "unicode" + + "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" +) + +// StructTags is a Go-struct fact source for tests: one fact per exported, +// direct field of a struct (msg is a struct value or a pointer to one), +// with the annotations its `facts` tag lists: +// +// type Individual struct { +// ID int64 +// Email string `facts:"fides.data_categories=user.contact.email"` +// MedicareNo string `facts:"fides.data_categories=user.government_id,user.financial"` +// } +// +// The tag is `key=value[,value...]`, repeated with `;` for more keys. +// GoField is the Go field name and Field is its snake_case ("MedicareNo" +// is "medicare_no", "ID" is "id", "HTTPPort" is "http_port"): the name a +// proto field or a database column would have, so the column identity a +// field binds by default is the one the Rust derive and the schema spell. +// Number is 0 and Kind is the field's reflect.Kind (through one pointer). +// +// Unexported and embedded fields are not facts: a plan binds exported, +// direct fields only. A `facts` tag on one — or on any field of an +// embedded struct — is an error, not a field quietly left in plaintext. +var StructTags plan.Source = plan.SourceFunc(structFacts) + +func structFacts(msg any) ([]plan.Fact, error) { + t := reflect.TypeOf(msg) + if t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t == nil || t.Kind() != reflect.Struct { + return nil, fmt.Errorf("factstest: StructTags reads structs, not %T", msg) + } + facts := make([]plan.Fact, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if !sf.IsExported() || sf.Anonymous { + if err := refuseUnbindableTag(t, sf); err != nil { + return nil, fmt.Errorf("factstest: %s.%s: %w", t, sf.Name, err) + } + continue + } + kind := sf.Type + if kind.Kind() == reflect.Pointer { + kind = kind.Elem() + } + annotations, err := parseFactsTag(sf.Tag.Get("facts")) + if err != nil { + return nil, fmt.Errorf("factstest: %s.%s: %w", t, sf.Name, err) + } + facts = append(facts, plan.Fact{ + Message: t.String(), + Field: snakeCase(sf.Name), + GoField: sf.Name, + Kind: kind.Kind().String(), + Annotations: annotations, + }) + } + return facts, nil +} + +// refuseUnbindableTag is the error for a `facts` tag on a field sf of +// outer that a plan cannot bind: an unexported or embedded field, or any +// field of an embedded struct, however deep. The tag says the field is +// classified; dropping it would store the field in plaintext with no rule +// ever asked. A struct that embeds itself (`type Node struct { *Node; ... +// }`) is not searched again: its fields are outer's own, already read. +func refuseUnbindableTag(outer reflect.Type, sf reflect.StructField) error { + if sf.Tag.Get("facts") != "" { + if sf.Anonymous { + return errors.New("a facts tag on an embedded field, which a plan cannot bind") + } + return errors.New("a facts tag on an unexported field, which a plan cannot bind") + } + if !sf.Anonymous { + return nil + } + if tagged := firstFactsTag(sf.Type, []reflect.Type{outer}); tagged != "" { + return fmt.Errorf("embedded %s has a facts tag on %s, which a plan cannot bind; make it a direct field", sf.Type, tagged) + } + return nil +} + +// firstFactsTag names the first field of t (a struct, through one +// pointer), or of a struct embedded in it, that carries a facts tag; "" +// when none does. seen is the structs already on the path down: an +// embedding can be recursive (`type Node struct { *Node; ... }`), and a +// struct already being searched has nothing new to find. +func firstFactsTag(t reflect.Type, seen []reflect.Type) string { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct || slices.Contains(seen, t) { + return "" + } + seen = append(seen, t) + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if sf.Tag.Get("facts") != "" { + return sf.Name + } + if sf.Anonymous { + if name := firstFactsTag(sf.Type, seen); name != "" { + return sf.Name + "." + name + } + } + } + return "" +} + +// snakeCase is a Go field name as a schema would spell it: a lower-case +// word per hump, joined by underscores, with an initialism kept as one +// word ("HTTPPort" is "http_port", "ID" is "id"). Digits stay with the +// word before them ("Line2" is "line2"). +func snakeCase(name string) string { + runes := []rune(name) + var b strings.Builder + b.Grow(len(name) + 4) + for i, r := range runes { + if i > 0 && unicode.IsUpper(r) { + prev := runes[i-1] + nextLower := i+1 < len(runes) && unicode.IsLower(runes[i+1]) + if unicode.IsLower(prev) || unicode.IsDigit(prev) || (unicode.IsUpper(prev) && nextLower) { + b.WriteByte('_') + } + } + b.WriteRune(unicode.ToLower(r)) + } + return b.String() +} + +func parseFactsTag(tag string) ([]plan.Annotation, error) { + if tag == "" { + return nil, nil + } + var out []plan.Annotation + for _, part := range strings.Split(tag, ";") { + key, values, ok := strings.Cut(part, "=") + if !ok || key == "" || values == "" { + return nil, fmt.Errorf("facts tag %q: want key=value[,value...]", part) + } + if slices.ContainsFunc(out, func(a plan.Annotation) bool { return a.Key == key }) { + return nil, fmt.Errorf("facts tag: key %q given twice", key) + } + vs := strings.Split(values, ",") + if slices.Contains(vs, "") { + return nil, errors.New("facts tag: empty value for " + key) + } + out = append(out, plan.Annotation{Key: key, Values: vs}) + } + return out, nil +} diff --git a/languages/golang/internal/factstest/factstest_test.go b/languages/golang/internal/factstest/factstest_test.go new file mode 100644 index 000000000..b3e4b7b1c --- /dev/null +++ b/languages/golang/internal/factstest/factstest_test.go @@ -0,0 +1,118 @@ +package factstest_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/internal/factstest" + "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" +) + +// Recursive embeddings, legal in Go, which a scan of embedded structs must +// not follow forever. +type ( + list struct { + *list //nolint:unused // the recursion is the point + Value string + } + node struct { + *node //nolint:unused // the recursion is the point + Secret string `facts:"a=x"` + } +) + +func TestStructTags(t *testing.T) { + type embedded struct{ Inner string } + type row struct { + embedded + ID *int64 + Email string `facts:"a=x,y;b=z"` + hidden string //nolint:unused // proves untagged unexported fields are skipped + } + facts, err := factstest.StructTags.Facts(&row{}) + if err != nil { + t.Fatal(err) + } + want := []plan.Fact{ + {Message: "factstest_test.row", Field: "id", GoField: "ID", Kind: "int64"}, + {Message: "factstest_test.row", Field: "email", GoField: "Email", Kind: "string", Annotations: []plan.Annotation{ + {Key: "a", Values: []string{"x", "y"}}, {Key: "b", Values: []string{"z"}}, + }}, + } + if !reflect.DeepEqual(facts, want) { + t.Fatalf("facts =\n%+v\nwant\n%+v", facts, want) + } + if got := facts[1].String(); got != "factstest_test.row.email (Email) [a=x,y; b=z]" { + t.Errorf("String = %s", got) + } + type taggedInner struct { + Secret string `facts:"a=x"` + } + type deeper struct{ taggedInner } + for name, bad := range map[string]struct { + msg any + say string + }{ + "not a struct": {42, "reads structs"}, + "nil": {nil, "reads structs"}, + "no value": {struct { + A string `facts:"a="` + }{}, "key=value"}, + "no key": {struct { + A string `facts:"=x"` + }{}, "key=value"}, + "empty value": {struct { + A string `facts:"a=x,"` + }{}, "empty value"}, + "key twice": {struct { + A string `facts:"a=x;a=y"` + }{}, "given twice"}, + // A tag the plan cannot bind is refused, never quietly plaintext. + "tagged unexported field": {struct { + medicareNo string `facts:"a=x"` //nolint:unused // the tag is the point + }{}, "unexported field"}, + "tagged embedded field": {struct { + embedded `facts:"a=x"` + }{}, "embedded field"}, + "tag inside an embedded struct": {struct{ taggedInner }{}, "Secret"}, + "tag two embeddings deep": {struct{ deeper }{}, "taggedInner.Secret"}, + "tag inside an embedded pointer": {struct{ *taggedInner }{}, "Secret"}, + } { + _, err := factstest.StructTags.Facts(bad.msg) + if err == nil { + t.Errorf("%s: facts read", name) + } else if !strings.Contains(err.Error(), bad.say) { + t.Errorf("%s: err = %q, want it to say %q", name, err, bad.say) + } + } + // A recursive embedding terminates, and is not a fact: the embedded + // copy's fields are the struct's own, tagged or not. + if facts, err := factstest.StructTags.Facts(list{}); err != nil || len(facts) != 1 || facts[0].GoField != "Value" { + t.Errorf("recursive embedding: facts %+v, %v; want Value alone", facts, err) + } + if facts, err := factstest.StructTags.Facts(node{}); err != nil || len(facts) != 1 || facts[0].GoField != "Secret" || len(facts[0].Annotations) != 1 { + t.Errorf("recursive embedding beside a tag: facts %+v, %v; want Secret alone, classified", facts, err) + } + // The schema spelling of a Go field name. + type spelled struct { + ID int64 + Email string + HTTPPort int + MedicareNo string + Line2 string + UserID string + OAuth2Key string + } + got, err := factstest.StructTags.Facts(spelled{}) + if err != nil { + t.Fatal(err) + } + names := make([]string, len(got)) + for i, f := range got { + names[i] = f.Field + } + if want := []string{"id", "email", "http_port", "medicare_no", "line2", "user_id", "o_auth2_key"}; !reflect.DeepEqual(names, want) { + t.Errorf("schema names = %v, want %v", names, want) + } +} diff --git a/languages/golang/internal/guest/call.go b/languages/golang/internal/guest/call.go new file mode 100644 index 000000000..0d624af76 --- /dev/null +++ b/languages/golang/internal/guest/call.go @@ -0,0 +1,130 @@ +package guest + +import ( + "context" + "errors" + "fmt" + "math" + + "github.com/tetratelabs/wazero/api" +) + +// The call plumbing every guest package uses to drive an export: stage +// each buffer argument into guest memory through the guest's se_alloc, +// call, copy the output out, and wipe and free every buffer — inputs and +// output — before returning. The memory stays mapped for the whole call +// (see Allocator.Free). + +// ErrTrap marks a guest export that did not return: a trap (the guests +// build with panic-as-abort, so an allocation one cannot make or an +// invariant it cannot keep ends in `unreachable`), or a module closed +// under it. The caller closes the instance on it: the guest's state after +// an abort is unknown, and its memory is better wiped than reused. +var ErrTrap = errors.New("cipherstash: guest did not return") + +// Buf is a host-owned allocation inside guest linear memory. +type Buf struct { + Ptr, Len uint32 +} + +// Arg is one guest-call argument: a buffer (staged into guest memory and +// passed as a (ptr, len) pair) or a scalar passed as is. +type Arg struct { + data []byte + scalar uint64 + isBuf bool +} + +// BufArg is a buffer argument. +func BufArg(data []byte) Arg { return Arg{data: data, isBuf: true} } + +// ScalarArg is a scalar argument. +func ScalarArg(v uint64) Arg { return Arg{scalar: v} } + +// Exports is the pair of exports every guest has, resolved on one module. +type Exports struct { + Alloc, Dealloc api.Function +} + +// AllocWrite stages data into a fresh guest buffer. +func (e Exports) AllocWrite(ctx context.Context, m api.Module, data []byte) (Buf, error) { + // se_alloc takes an i32: a longer length would reach the guest + // truncated to its low 32 bits. + if uint64(len(data)) > math.MaxUint32 { + return Buf{}, errors.New("cipherstash: buffer exceeds the guest's 4 GiB address space") + } + res, err := e.Alloc.Call(ctx, uint64(len(data))) + if err != nil { + return Buf{}, fmt.Errorf("%w: guest alloc: %w", ErrTrap, err) + } + b := Buf{Ptr: api.DecodeU32(res[0]), Len: uint32(len(data))} //nolint:gosec // bounded above + if b.Ptr == 0 { + return Buf{}, errors.New("cipherstash: guest allocation failed") + } + if len(data) > 0 && !m.Memory().Write(b.Ptr, data) { + e.Free(ctx, b) + return Buf{}, errors.New("cipherstash: guest memory write out of range") + } + return b, nil +} + +// Free zeroizes and releases a guest buffer (se_dealloc wipes; an unknown +// pointer is a no-op there). It runs under a context that cannot be +// cancelled: a caller's deadline expiring after the guest call returned +// must not skip the wipe of the buffers that call staged. +func (e Exports) Free(ctx context.Context, b Buf) { + if b.Ptr != 0 { + _, _ = e.Dealloc.Call(context.WithoutCancel(ctx), uint64(b.Ptr), uint64(b.Len)) + } +} + +// Call stages every buffer argument, calls fn with the arguments in +// order, and copies the output out before every buffer — inputs and +// output — is wiped and freed. mem is the module's allocator, held +// mapped for the whole call. +func Call(ctx context.Context, mem *Allocator, m api.Module, e Exports, fn api.Function, args ...Arg) ([]byte, error) { + mem.Enter() + defer mem.Exit() + var bufs []Buf + defer func() { + for _, b := range bufs { + e.Free(ctx, b) + } + }() + params := make([]uint64, 0, 2*len(args)) + for _, a := range args { + if !a.isBuf { + params = append(params, a.scalar) + continue + } + staged, err := e.AllocWrite(ctx, m, a.data) + if err != nil { + return nil, err + } + bufs = append(bufs, staged) + params = append(params, uint64(staged.Ptr), uint64(staged.Len)) + } + res, err := fn.Call(ctx, params...) + if err != nil { + return nil, fmt.Errorf("%w: guest call: %w", ErrTrap, err) + } + ptr, n, err := PackedResult(res[0]) + if err != nil { + return nil, err + } + out := Buf{Ptr: ptr, Len: n} + bufs = append(bufs, out) + view, ok := m.Memory().Read(out.Ptr, out.Len) + if !ok { + return nil, errors.New("cipherstash: guest returned an out-of-range buffer") + } + // Copy out before the deferred free wipes the guest-side buffer. + result := make([]byte, len(view)) + copy(result, view) + return result, nil +} + +// Wipe zeroes a host buffer. +func Wipe(b []byte) { + clear(b) +} diff --git a/languages/golang/internal/guest/clientkey.go b/languages/golang/internal/guest/clientkey.go new file mode 100644 index 000000000..6773dea68 --- /dev/null +++ b/languages/golang/internal/guest/clientkey.go @@ -0,0 +1,74 @@ +package guest + +import "fmt" + +// ClientKey is the ZeroKMS client key: long-lived key material that lives +// for the process. It is opaque on purpose. A string is immutable and +// cannot be wiped; a byte slice prints its contents under %v. This type +// prints a redaction under every verb, whether formatted as a value or a +// pointer, hands its bytes only to this package tree, and is wiped once +// consumed (see ADR-0005, decision 5). +// +// stackauth reads one out of the developer profile; stackencrypt takes it +// in its credentials and wipes it once the key is in guest memory. Both +// expose this type as an alias, so a key read by one is the type the other +// takes, without stackauth importing stackencrypt. +// +// The public packages alias the type, and an alias carries every exported +// method with it — Go's internal rule stops the import, not the call. So +// the accessor is a function of this package, KeyBytes, not a method: a +// caller outside internal can construct, wipe and print a key, and nothing +// else. +type ClientKey struct { + bytes []byte +} + +// NewClientKey wraps key material. It takes ownership of b: the caller must +// not keep or reuse the slice, which is wiped along with the key. +func NewClientKey(b []byte) *ClientKey { + return &ClientKey{bytes: b} +} + +// KeyBytes is the key material, for the package that marshals it into +// guest memory. The slice is the key's own: do not retain it, and call +// Wipe once it has been copied where it is going. A function rather than +// a method so it does not travel with the alias (see ClientKey). +func KeyBytes(k *ClientKey) []byte { + if k == nil { + return nil + } + return k.bytes +} + +// Wipe zeroes the key material. A wiped key is empty; a second Wipe is a +// no-op. +func (k *ClientKey) Wipe() { + if k == nil { + return + } + clear(k.bytes) + k.bytes = nil +} + +// IsZero reports whether the key holds no material: never set, or wiped. +func (k *ClientKey) IsZero() bool { + return k == nil || len(k.bytes) == 0 +} + +// Format implements fmt.Formatter, which fmt consults before Stringer and +// GoStringer and for every verb — %d and %x included, which would +// otherwise print the field. A value receiver, so a copied key redacts as +// the pointer does. The one thing fmt prints without asking is a nil +// pointer, as "<nil>"; there is no material behind one. +func (ClientKey) Format(f fmt.State, _ rune) { + _, _ = f.Write([]byte(redactedClientKey)) +} + +// String implements fmt.Stringer with the same redaction, for callers that +// call it directly rather than through fmt. +func (ClientKey) String() string { return redactedClientKey } + +// GoString implements fmt.GoStringer with the same redaction. +func (ClientKey) GoString() string { return redactedClientKey } + +const redactedClientKey = "ClientKey(***)" diff --git a/languages/golang/internal/guest/doc.go b/languages/golang/internal/guest/doc.go new file mode 100644 index 000000000..298e963b8 --- /dev/null +++ b/languages/golang/internal/guest/doc.go @@ -0,0 +1,14 @@ +// Package guest is what the Go packages over the WASI guests share and +// neither should own: the locked, non-dumpable memory a guest instance runs +// in; the status table every guest reports through and the errors it +// decodes to; and the opaque client key one package reads and the other +// consumes. +// +// It sits under internal so that stackencrypt and stackauth expose what +// they need of it — the error sentinels, the ClientKey type — as their own +// identifiers (aliases, not copies: an error from either package is the +// same value, and a key read by one is the type the other takes) without +// either package importing the other. A binary that wants only the profile +// must not carry the crypto guest, and this is the seam that makes that +// true. See ADR-0005 in packages/stack-encrypt/docs/adr. +package guest diff --git a/languages/golang/internal/guest/errors.go b/languages/golang/internal/guest/errors.go new file mode 100644 index 000000000..22be05305 --- /dev/null +++ b/languages/golang/internal/guest/errors.go @@ -0,0 +1,94 @@ +package guest + +import "errors" + +// Failure kinds a guest surfaces across the boundary. A guest reports a +// status code and nothing else (see StatusError), so these are the whole +// vocabulary: they separate a tampered ciphertext from a bad token from a +// malformed input, and reveal nothing about plaintext or key material. The +// public packages expose them under their own names; the values are these. +var ( + // ErrAuthentication is an AEAD open failure: a tampered ciphertext, a + // wrong element derivation, or a wrong AAD that reached the AEAD. Against + // ZeroKMS a wrong AAD is usually refused earlier as ErrForbidden, because + // every data key is bound to its context. + ErrAuthentication = errors.New("cipherstash: authentication failed") + // ErrEncoding is a malformed input: a value, ciphertext, plan, context, + // selector, path or config the guest refused before doing anything with + // it. + ErrEncoding = errors.New("cipherstash: malformed input") + // ErrState is a call on something that has been closed: a client, a + // store, or an instance an interrupted call took down. + ErrState = errors.New("cipherstash: closed") + // ErrInternal is a guest panic or any other unexpected guest failure. + ErrInternal = errors.New("cipherstash: internal guest failure") + // ErrUnauthorized is ZeroKMS refusing the bearer token (HTTP 401): the + // token is invalid, expired, or for another workspace. + ErrUnauthorized = errors.New("cipherstash: ZeroKMS rejected the access token") + // ErrForbidden is ZeroKMS refusing the request (HTTP 403): the token is + // valid but not permitted, or a data key's bound context did not match + // the one presented — the production form of a wrong-AAD open. + ErrForbidden = errors.New("cipherstash: ZeroKMS refused the request") + // ErrNotFound is ZeroKMS reporting a missing resource (HTTP 404): an + // unknown keyset name or id, or a data key that does not exist. + ErrNotFound = errors.New("cipherstash: ZeroKMS resource not found") + // ErrConflict is ZeroKMS reporting a resource conflict (HTTP 409). + ErrConflict = errors.New("cipherstash: ZeroKMS resource conflict") + // ErrTransport is a failure to reach ZeroKMS or to read its response: + // the transport returned an error, or the endpoint could not be resolved. + ErrTransport = errors.New("cipherstash: ZeroKMS transport failed") + // ErrKMS is any other ZeroKMS failure: an unparseable response, invalid + // key material, or an unclassified server error. + ErrKMS = errors.New("cipherstash: ZeroKMS request failed") + // ErrTerm is a term derivation the scheme could not perform for the + // given input, such as match text that yields no tokens. + ErrTerm = errors.New("cipherstash: term derivation failed") + // ErrForeignKeyset is a keyset-bound cipher refusing a ciphertext sealed + // under another keyset, before any key is retrieved. + ErrForeignKeyset = errors.New("cipherstash: ciphertext belongs to another keyset") + // ErrProfileIO is a profile file that could not be read or written. + ErrProfileIO = errors.New("cipherstash: profile file could not be read or written") + // ErrProfileJSON is a profile file that is not the JSON its type expects. + ErrProfileJSON = errors.New("cipherstash: profile file is not valid") + // ErrProfileNotFound is a profile file that does not exist in the store + // asked: no secretkey.json, auth.json or device.json there. + ErrProfileNotFound = errors.New("cipherstash: profile file not found") + // ErrInvalidFilename is a filename the store refuses: empty, absolute, + // or naming a path. + ErrInvalidFilename = errors.New("cipherstash: invalid profile filename") + // ErrNoCurrentWorkspace is a workspace-scoped operation with no current + // workspace set. + ErrNoCurrentWorkspace = errors.New("cipherstash: no current workspace; run `stash auth login`") + // ErrInvalidWorkspaceID is a workspace id that is not sixteen base32 + // characters. + ErrInvalidWorkspaceID = errors.New("cipherstash: invalid workspace id") + // ErrWorkspaceNotFound is a workspace with no local profile data: nothing + // has logged in to it on this machine. + ErrWorkspaceNotFound = errors.New("cipherstash: workspace has no local profile; log in to it first") + // ErrAuthInvalidGrant is an OAuth refresh grant the auth server rejected. + ErrAuthInvalidGrant = errors.New("cipherstash: auth server rejected the refresh grant") + // ErrAuthInvalidClient is a client credential the auth server rejected. + ErrAuthInvalidClient = errors.New("cipherstash: auth server rejected the client") + // ErrAuthUsageLimit is an account blocked by its usage allowance. + ErrAuthUsageLimit = errors.New("cipherstash: account usage limit exceeded") + // ErrAuthNotAuthenticated means no usable auth credential is available. + ErrAuthNotAuthenticated = errors.New("cipherstash: no usable authentication credential") + // ErrAuthTransport is a failed auth HTTP exchange or response read. + ErrAuthTransport = errors.New("cipherstash: auth transport failed") + // ErrAuthConfig is invalid auth configuration or token data. + ErrAuthConfig = errors.New("cipherstash: invalid auth configuration or token") + // ErrAuthOther is an auth failure outside the actionable categories above. + ErrAuthOther = errors.New("cipherstash: authentication failed") + // ErrAuthRefreshRequired tells the Go credential host to take the + // cross-process lock and call the device-session refresh export. + ErrAuthRefreshRequired = errors.New("cipherstash: device session needs refresh") + // ErrMemoryLock is guest memory that could not be locked in RAM (or, + // on Linux, excluded from core dumps). A constructor returns it when + // asked for locked memory and refused, and so does any later call under + // that setting whose growth of the guest's memory could not be locked; + // otherwise the instance reports it and works on with unlocked memory. + // The wrapped error names the limit that refused the lock and the size + // the guest holds: on Linux, RLIMIT_MEMLOCK (ulimit -l, a systemd + // LimitMEMLOCK=, or a pod's securityContext). + ErrMemoryLock = errors.New("cipherstash: guest memory is not locked") +) diff --git a/languages/golang/internal/guest/heap_test.go b/languages/golang/internal/guest/heap_test.go new file mode 100644 index 000000000..5c3469c6c --- /dev/null +++ b/languages/golang/internal/guest/heap_test.go @@ -0,0 +1,40 @@ +package guest + +import ( + "math" + "testing" +) + +const wasmPage = 64 * 1024 + +// The heap fallback keeps the two properties it can: growth wipes the +// slice it abandons, and Free wipes. +func TestHeapMemoryWipesWhatItAbandons(t *testing.T) { + m := newHeapMemory(wasmPage, 4*wasmPage) + first, _ := m.commit(wasmPage) + first[0], first[wasmPage-1] = 0xAA, 0xBB + second, _ := m.commit(3 * wasmPage) + if second[0] != 0xAA || second[wasmPage-1] != 0xBB { + t.Fatal("growth lost the contents") + } + if first[0] != 0 || first[wasmPage-1] != 0 { + t.Fatal("growth left the abandoned slice unwiped") + } + if buf, _ := m.commit(5 * wasmPage); buf != nil { + t.Fatal("grew past max") + } + // A size no slice on this host can hold is a refused growth, not a + // panic. Only a 32-bit host can ask without the request being a real + // allocation, so that is where it runs (CI's GOARCH=386 pass). + if uint64(math.MaxInt) < 1<<40 { + huge := newHeapMemory(0, 1<<40) + if buf, _ := huge.commit(1 << 40); buf != nil { + t.Fatal("a growth past the addressable size was granted") + } + } + second[7] = 0xCC + m.free() + if second[7] != 0 { + t.Fatal("free left the slice unwiped") + } +} diff --git a/languages/golang/internal/guest/memory.go b/languages/golang/internal/guest/memory.go new file mode 100644 index 000000000..aabbbbeac --- /dev/null +++ b/languages/golang/internal/guest/memory.go @@ -0,0 +1,330 @@ +package guest + +import ( + "fmt" + "log/slog" + "math" + "sync" + + "github.com/tetratelabs/wazero/experimental" +) + +// The guest's linear memory is where every key lives: the client key from +// NewClient on, each keyset's index key once loaded, and each data key for +// the duration of a call. The host owns that memory, so the host decides +// what can happen to it. This allocator supplies the guest's memory from a +// reservation of its own rather than from wazero's default Go slice, so +// that: +// +// - the buffer never moves. wazero's default grows non-shared memory +// with append, which copies the whole linear memory — keys included — +// into a new slice and leaves the old one to the garbage collector, +// unwiped. Here the declared maximum is reserved up front and growth +// commits more of the same reservation; +// - the committed pages are locked where the platform allows, so they +// are never written to swap; +// - they are excluded from core dumps where the platform allows (Linux); +// - the committed range is wiped before it is released, on every +// release path, so a freed instance leaves nothing behind. +// +// None of this depends on Close running. A process that dies to SIGKILL, +// the OOM killer, a panic on another goroutine or os.Exit leaves its keys +// in memory the kernel will zero before anyone else sees it, and — with +// the lock and the dump exclusion in place — nowhere else. Close still runs +// the guest's own wipe for the orderly path; it is hygiene, not the +// security story. +// +// The lock is best effort by default. RLIMIT_MEMLOCK defaults to 64 KiB on +// many Linux hosts and the guest's memory is larger, so the lock is +// commonly refused, with nothing else lost: the pages can be swapped, and +// on a host with no swap not even that. The refusal is recorded and +// reported through LockError, which each public package surfaces on its +// client (stackencrypt: Client.MemoryLocked and Client.MemoryLockError) so +// an operator can see it and raise the limit; Strict turns it into a +// constructor failure (stackencrypt: WithRequireLockedMemory). + +// LockPolicy is what a refused lock means for an instance. +type LockPolicy uint8 + +const ( + // BestEffort records a refused lock and carries on with unlocked + // memory. + BestEffort LockPolicy = iota + // Strict refuses growth that cannot be locked. The first commit is the + // exception: wazero cannot instantiate on a nil buffer, so it is + // granted with the refusal recorded, and the public package's + // constructor turns that into the ErrMemoryLock the caller asked for. + Strict +) + +// PolicyFor is the policy a caller's "require locked memory" setting +// means: Strict when set, BestEffort otherwise. +func PolicyFor(requireLockedMemory bool) LockPolicy { + if requireLockedMemory { + return Strict + } + return BestEffort +} + +// backend is one platform's linear memory behind an Allocator: a +// reservation committed from the front. It is used from the guest's +// goroutine only; the allocator does the bookkeeping other goroutines +// read. +type backend interface { + // commit grows the memory to size bytes and returns the buffer wazero + // will use, whose base never changes. A nil buffer means the growth + // failed. lockErr, when set, is a refused lock on the newly committed + // range: with a buffer, the range was kept unlocked (best effort); + // without one, the growth was refused because of it (Strict). + commit(size uint64) (buf []byte, lockErr error) + // free wipes the committed range and releases the reservation. + free() +} + +// Allocator is the experimental.MemoryAllocator handed to wazero for +// one guest instance, and the experimental.LinearMemory it returns: wazero +// calls Allocate once per memory, and the guest has exactly one. It +// records what the public package reports about the memory, and holds the +// memory mapped while a guest call is in flight (see Enter, Exit and +// Free). +type Allocator struct { + policy LockPolicy + + mu sync.Mutex + backing backend + // fallback is set when the backing is a heap slice rather than a + // reservation: no lock is possible, growth may copy (and wipes what + // it abandons). + fallback bool + // lockErr is the first refusal that left the guest holding + // unprotected memory — the reservation, the dump exclusion, a lock on + // a range that was kept — and never clears: a lock refused once is + // reported for the life of the instance. + lockErr error + // growth is the Strict growths refused. It is not lockErr: a refused + // growth gives its range back before the guest sees it, so every byte + // the guest holds is still locked and the instance still reports so. + growth GrowthRefusal + // inFlight counts guest calls in progress (see enter and exit); + // pending records a Free that arrived while one was, to be honoured + // when the outermost call returns. + inFlight int + pending bool + // freed is set once the memory is gone, its contents wiped first. + // Tests read it to observe release paths the caller never sees, such + // as the cleanup on an unreachable Client. + freed bool +} + +// GrowthRefusal is the Strict growths an allocator has refused: how many, +// and the lock refusal behind the latest. A caller compares the count +// across a guest call to name the real cause when the guest reports only +// a failed allocation. +type GrowthRefusal struct { + Refused uint64 + Reason error +} + +// NewAllocator is an allocator for one guest instance under policy. Hand +// it to wazero as the instance's experimental.MemoryAllocator; it +// allocates when the guest's memory is first instantiated. +func NewAllocator(policy LockPolicy) *Allocator { + return &Allocator{policy: policy} +} + +// Allocate implements experimental.MemoryAllocator. +func (a *Allocator) Allocate(capacity, max uint64) experimental.LinearMemory { + a.mu.Lock() + defer a.mu.Unlock() + if a.backing != nil { + // The guest has one memory; a second would mean wazero's contract + // changed under us. Refusing here fails instantiation loudly + // rather than letting two memories share one report. + panic("cipherstash: guest memory allocated twice") + } + backing, err := reserveMemory(capacity, max, a.policy) + a.backing = backing + a.lockErr = err + _, a.fallback = backing.(*heapMemory) + return a +} + +// Reallocate implements experimental.LinearMemory. +func (a *Allocator) Reallocate(size uint64) []byte { + buf, lockErr := a.backing.commit(size) + if lockErr != nil { + a.mu.Lock() + if buf == nil { + // Strict: the range was given back, so nothing unlocked was + // admitted and the lock report stands. + a.growth.Refused++ + a.growth.Reason = lockErr + } else if a.lockErr == nil { + a.lockErr = lockErr + } + a.mu.Unlock() + } + return buf +} + +// Free implements experimental.LinearMemory. wazero calls it when the +// module's resources are closed, and that can happen while the guest is +// still running: a call whose context ends during a host import closes +// the module on wazero's watcher goroutine with its resources deferred, +// and the next call into the module — the host import re-entering the +// guest through se_alloc to place its result — closes them. With wazero's +// default allocator that was harmless, the Go slice outlived the module; +// here it would unmap the memory under a guest suspended in the import, +// whose next store then faults in compiled code. So a Free that arrives +// during a call is recorded and performed by the outermost exit, when no +// guest code can be running. A Free with no call in flight is immediate. +func (a *Allocator) Free() { + a.mu.Lock() + defer a.mu.Unlock() + if a.inFlight > 0 { + a.pending = true + return + } + a.freeLocked() +} + +// Enter marks a guest call in progress: the memory must stay mapped until +// the matching Exit, whatever wazero asks in between. +func (a *Allocator) Enter() { + a.mu.Lock() + a.inFlight++ + a.mu.Unlock() +} + +// Exit ends a guest call and performs a Free that arrived during it. +func (a *Allocator) Exit() { + a.mu.Lock() + defer a.mu.Unlock() + a.inFlight-- + if a.inFlight == 0 && a.pending { + a.pending = false + a.freeLocked() + } +} + +// freeLocked wipes and releases the memory, once. Called with mu held. +func (a *Allocator) freeLocked() { + if a.freed { + return + } + a.freed = true + a.backing.free() +} + +// LockError is nil while every committed byte is locked (and, on Linux, +// excluded from dumps); otherwise it names what was refused and why. +func (a *Allocator) LockError() error { + a.mu.Lock() + defer a.mu.Unlock() + return a.lockErr +} + +// GrowthRefusal is the Strict growths refused so far. +func (a *Allocator) GrowthRefusal() GrowthRefusal { + a.mu.Lock() + defer a.mu.Unlock() + return a.growth +} + +// String is the memory's state for a log line: "locked", or the refusal. +// Nothing secret is printed. +func (a *Allocator) String() string { + if err := a.LockError(); err != nil { + return fmt.Sprintf("unlocked: %v", err) + } + return "locked" +} + +// LogValue is the same state for slog: a group with memory_locked and, +// when false, memory_lock_error. +func (a *Allocator) LogValue() slog.Value { + if err := a.LockError(); err != nil { + return slog.GroupValue(slog.Bool("memory_locked", false), slog.String("memory_lock_error", err.Error())) + } + return slog.GroupValue(slog.Bool("memory_locked", true)) +} + +// IsFallback reports whether the guest runs on the heap fallback rather +// than a reservation: nothing is locked, and growth may copy. +func (a *Allocator) IsFallback() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.fallback +} + +// IsFreed reports whether the memory has been wiped and released. Tests +// read it to observe release paths a caller never sees. +func (a *Allocator) IsFreed() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.freed +} + +// heapMemory backs the guest with an ordinary Go slice, for platforms with +// no reservation primitive this package uses and for a reservation that +// failed (a 4 GiB address-space reservation on a 32-bit host, say). It +// keeps two of the four properties above: growth wipes the slice it +// abandons, and free wipes before releasing. It cannot lock or exclude +// from dumps; the allocator carries the reason. +type heapMemory struct { + buf []byte + max uint64 +} + +// size implements sized, for the testing seam. +func (m *heapMemory) size() uint64 { return uint64(len(m.buf)) } + +func newHeapMemory(capacity, max uint64) *heapMemory { + if capacity > max { + capacity = max + } + if capacity > math.MaxInt { + capacity = 0 + } + return &heapMemory{buf: make([]byte, 0, int(capacity)), max: max} +} + +func (m *heapMemory) commit(size uint64) ([]byte, error) { + if size > m.max || size > math.MaxInt { + return nil, nil + } + if size <= uint64(cap(m.buf)) { + m.buf = m.buf[:size] + return m.buf, nil + } + grown := make([]byte, size) + copy(grown, m.buf) + // The abandoned slice held everything the guest had, keys included. + clear(m.buf[:cap(m.buf)]) + m.buf = grown + return m.buf, nil +} + +func (m *heapMemory) free() { + clear(m.buf[:cap(m.buf)]) + m.buf = nil +} + +// MemoryLockError wraps a lock refusal as ErrMemoryLock. +func MemoryLockError(err error) error { + return fmt.Errorf("%w: %w", ErrMemoryLock, err) +} + +func byteCount(n uint64) string { + const kib, mib, gib = 1 << 10, 1 << 20, 1 << 30 + switch { + case n >= gib: + return fmt.Sprintf("%.1f GiB", float64(n)/gib) + case n >= mib: + return fmt.Sprintf("%.1f MiB", float64(n)/mib) + case n >= kib: + return fmt.Sprintf("%d KiB", n/kib) + default: + return fmt.Sprintf("%d bytes", n) + } +} diff --git a/languages/golang/internal/guest/memory_linux.go b/languages/golang/internal/guest/memory_linux.go new file mode 100644 index 000000000..180916877 --- /dev/null +++ b/languages/golang/internal/guest/memory_linux.go @@ -0,0 +1,22 @@ +package guest + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +// The reservation is address space, not memory: nothing is charged against +// the overcommit limit until a range is committed. +const reserveFlags = unix.MAP_NORESERVE + +// excludeFromDumps marks the whole reservation MADV_DONTDUMP. The flag +// lives on the mapping and survives the mprotect calls that later split it +// into committed and reserved parts, so once is enough; the smaps test +// pins that on a committed range. +func excludeFromDumps(mapping []byte) error { + if err := unix.Madvise(mapping, unix.MADV_DONTDUMP); err != nil { + return fmt.Errorf("excluding guest memory from core dumps: %w", err) + } + return nil +} diff --git a/languages/golang/internal/guest/memory_linux_test.go b/languages/golang/internal/guest/memory_linux_test.go new file mode 100644 index 000000000..0887e6e61 --- /dev/null +++ b/languages/golang/internal/guest/memory_linux_test.go @@ -0,0 +1,21 @@ +package guest_test + +import ( + "testing" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guesttest" +) + +// The probe's mapping, on a range committed by Reallocate after the +// mprotect split, not only on what Allocate set up. The real guest's +// mapping is checked the same way where the guest is embedded. +func TestGuestMemoryIsLockedAndNotDumpable(t *testing.T) { + alloc := guest.NewAllocator(guest.BestEffort) + base, grow, done := guesttest.ProbeMemory(t, alloc) + defer done() + if _, ok := grow(2); !ok { + t.Fatal("grow refused") + } + guesttest.AssertMappingProtected(t, alloc, base()) +} diff --git a/languages/golang/internal/guest/memory_mapped.go b/languages/golang/internal/guest/memory_mapped.go new file mode 100644 index 000000000..fb17a7071 --- /dev/null +++ b/languages/golang/internal/guest/memory_mapped.go @@ -0,0 +1,97 @@ +//go:build unix || windows + +package guest + +import ( + "fmt" + "math" + "runtime" +) + +// mappedMemory is the reservation-backed memory: one range of the declared +// maximum reserved up front, inaccessible until committed (made readable +// and writable) from the front as the guest grows. The base never changes, +// so wazero's buffer never moves. Each newly committed range is locked; +// the whole reservation is excluded from dumps once, at reservation, on +// the platforms that can. The platform supplies the five primitives +// (memory_unix.go, memory_windows.go); the shape is the same on both. +type mappedMemory struct { + mapping []byte // the whole reservation + committed uint64 // bytes made accessible so far, from the front + policy LockPolicy +} + +// size implements sized, for the testing seam. +func (m *mappedMemory) size() uint64 { return m.committed } + +// reserveMemory returns a mapped memory for the reservation, or a heap +// memory when the reservation itself is impossible: max exceeds what this +// process can address (a 32-bit host asked for wasm's 4 GiB default), or +// the platform refused it. The error is the reason the memory is not, or +// not fully, protected; nil when it is. +func reserveMemory(capacity, max uint64, policy LockPolicy) (backend, error) { + if max > math.MaxInt { + return newHeapMemory(capacity, max), fmt.Errorf("cannot reserve %s of address space on this host", byteCount(max)) + } + mapping, err := reserveRange(int(max)) + if err != nil { + return newHeapMemory(capacity, max), fmt.Errorf("reserving %s of address space: %w", byteCount(max), err) + } + return &mappedMemory{mapping: mapping, policy: policy}, excludeFromDumps(mapping) +} + +// commit implements backend. Shrinking is not something wasm does; a +// smaller size just shortens the view. +func (m *mappedMemory) commit(size uint64) ([]byte, error) { + if size > uint64(len(m.mapping)) { + return nil, nil + } + if size > m.committed { + fresh := m.mapping[m.committed:size] + if err := commitRange(fresh); err != nil { + return nil, nil + } + if err := lockRange(fresh); err != nil { + // The platform names the range it could not lock; the + // operator sizing a limit needs the whole of what the guest + // holds with it. + err = fmt.Errorf("%w; the guest needs at least %s locked", err, byteCount(size)) + if m.policy == Strict && m.committed > 0 { + // Nothing was written to the range yet; giving it back + // leaves the guest exactly where it was. + decommitRange(fresh) + return nil, err + } + m.committed = size + return m.mapping[:size:size], err + } + m.committed = size + } + return m.mapping[:size:size], nil +} + +// free implements backend: wipe what was committed, then release the +// reservation, which drops any lock with the pages. +func (m *mappedMemory) free() { + if m.mapping == nil { + return + } + wipeMapped(m.mapping[:m.committed]) + releaseRange(m.mapping) + m.mapping = nil + m.committed = 0 +} + +// wipeMapped zeroes a committed range before its mapping is released. The +// stores go to memory that a syscall unmaps straight after, which the +// compiler cannot see through, so they are not dead stores it could drop; +// the volatile-store dance a C wipe needs has no Go equivalent and no need +// here. KeepAlive pins the slice past the stores so nothing reorders the +// wipe after the release. +func wipeMapped(b []byte) { + if len(b) == 0 { + return + } + clear(b) + runtime.KeepAlive(b) +} diff --git a/languages/golang/internal/guest/memory_other.go b/languages/golang/internal/guest/memory_other.go new file mode 100644 index 000000000..645998b72 --- /dev/null +++ b/languages/golang/internal/guest/memory_other.go @@ -0,0 +1,16 @@ +//go:build !unix && !windows + +package guest + +import "errors" + +// errNoLockSupport is the heap fallback's reason on platforms where this +// package has no lock implementation. +var errNoLockSupport = errors.New("guest memory cannot be locked on this platform") + +// Platforms with neither mmap nor VirtualAlloc in this package's +// vocabulary get the heap fallback: growth and release still wipe, nothing +// is locked, and the Client says so. +func reserveMemory(capacity, max uint64, _ LockPolicy) (backend, error) { + return newHeapMemory(capacity, max), errNoLockSupport +} diff --git a/languages/golang/internal/guest/memory_test.go b/languages/golang/internal/guest/memory_test.go new file mode 100644 index 000000000..738230888 --- /dev/null +++ b/languages/golang/internal/guest/memory_test.go @@ -0,0 +1,307 @@ +package guest_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guesttest" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/experimental" + "github.com/tetratelabs/wazero/sys" +) + +// The allocator on its own, under the grow probe. What it does for a real +// guest, and what a client reports about it, is tested where the guest is +// embedded (stackencrypt's memory tests). + +// The whole point of owning the allocation: growth commits more of one +// reservation, so the buffer's address is the same before and after, and +// the guest's keys are never copied to a new slice. +func TestGuestMemoryDoesNotMoveOnGrowth(t *testing.T) { + alloc := guest.NewAllocator(guest.BestEffort) + base, grow, done := guesttest.ProbeMemory(t, alloc) + defer done() + if alloc.IsFallback() { + t.Skipf("heap fallback in use on this host: %v", alloc.LockError()) + } + t.Logf("lock state on this host: %v", alloc.LockError()) + before := base() + for _, pages := range []uint32{1, 15, 64} { + if _, ok := grow(pages); !ok { + t.Fatalf("grow(%d) refused", pages) + } + if after := base(); after != before { + t.Fatalf("memory moved on grow(%d): %#x -> %#x", pages, before, after) + } + } +} + +// Free wipes then unmaps: the allocator reports the release, and the +// runtime close is what triggers it. +func TestGuestMemoryIsFreedOnRuntimeClose(t *testing.T) { + alloc := guest.NewAllocator(guest.BestEffort) + _, grow, done := guesttest.ProbeMemory(t, alloc) + if _, ok := grow(3); !ok { + t.Fatal("grow refused") + } + if alloc.IsFreed() { + t.Fatal("freed before close") + } + done() + if !alloc.IsFreed() { + t.Fatal("runtime close did not free the guest memory") + } +} + +// The refusal on a growth, from the kernel: with RLIMIT_MEMLOCK at two +// pages the probe's first page locks and a growth by two more cannot. +// Strict refuses the growth and gives the range back, so the page the +// probe holds is still locked and the allocator still says so; the +// refusal is reported on its own, naming the limit. +func TestRequireLockedMemoryRefusesAnUnlockableGrowth(t *testing.T) { + if !guesttest.HostReserves(t) { + t.Skip("heap fallback in use on this host: no reservation to lock") + } + if !guesttest.InChild(t) { + return + } + const limit = 2 * guesttest.WasmPage + if err := guesttest.SetMemlockLimit(limit); err != nil { + t.Fatalf("lowering RLIMIT_MEMLOCK: %v", err) + } + alloc := guest.NewAllocator(guest.Strict) + _, grow, done := guesttest.ProbeMemory(t, alloc) + defer done() + if err := alloc.LockError(); err != nil { + fmt.Printf("case skipped: the first page did not lock under RLIMIT_MEMLOCK=%d: %v\n", limit, err) + return + } + if _, ok := grow(2); ok { + fmt.Println("case skipped: mlock succeeds past RLIMIT_MEMLOCK") + return + } + if err := alloc.LockError(); err != nil { + t.Fatalf("a refused growth changed the lock report: %v", err) + } + g := alloc.GrowthRefusal() + if g.Refused != 1 || g.Reason == nil { + t.Fatalf("GrowthRefusal = %+v; want one, with the refusal", g) + } + if !strings.Contains(g.Reason.Error(), "RLIMIT_MEMLOCK") || !strings.Contains(g.Reason.Error(), "needs at least") { + t.Fatalf("the refusal does not name the limit and the size held: %v", g.Reason) + } + fmt.Println("case ok") +} + +// A refused growth is the growth's failure, not the memory's: the +// allocator counts it and keeps its reason, and the lock report — nil, +// or whatever this host refused at the start — is exactly what it was. +// Once the growth is let through the report is still unchanged. +func TestRefusedGrowthLeavesTheLockReportAlone(t *testing.T) { + alloc := guest.NewAllocator(guest.Strict) + base, grow, done := guesttest.ProbeMemory(t, alloc) + defer done() + before := alloc.LockError() + refusing := guest.RefuseGrowth(alloc, errors.New("refused for the test")) + at := base() + if _, ok := grow(1); ok { + t.Fatal("the refused growth was granted") + } + if after := alloc.LockError(); after != before { + t.Fatalf("the refused growth changed the lock report: %v -> %v", before, after) + } + if g := alloc.GrowthRefusal(); g.Refused != 1 || g.Reason != refusing.Reason() { + t.Fatalf("GrowthRefusal = %+v; want one, with the refusal", g) + } + refusing.Allow() + if _, ok := grow(1); !ok { + t.Fatal("growth refused once the backend lets it through") + } + if after := alloc.LockError(); after != before { + t.Fatalf("a later growth changed the lock report: %v -> %v", before, after) + } + if g := alloc.GrowthRefusal(); g.Refused != 1 { + t.Fatalf("GrowthRefusal = %+v after a granted growth, want one", g) + } + // The heap fallback may copy on growth, and says so; a reservation + // never does. + if !alloc.IsFallback() && base() != at { + t.Fatal("memory moved across the refused growth") + } +} + +// reentrantProbe is a hand-assembled module reproducing the shape of a +// guest's transport import: "run" calls the host function h, then stores +// to memory. h re-enters the guest (as transport_send does through +// se_alloc) with a context that has ended, which is how wazero comes to +// free the module's memory while the guest is suspended in the import: +// +// (module +// (import "env" "h" (func $h)) +// (memory (export "memory") 1) +// (func (export "run") call $h i32.const 0 i32.const 1 i32.store) +// (func (export "nop"))) +var reentrantProbe = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, // type: () -> () + 0x02, 0x09, 0x01, 0x03, 'e', 'n', 'v', 0x01, 'h', 0x00, 0x00, // import env.h + 0x03, 0x03, 0x02, 0x00, 0x00, // two functions of type 0 + 0x05, 0x03, 0x01, 0x00, 0x01, // memory: min 1, no max + 0x07, 0x16, 0x03, + 0x06, 'm', 'e', 'm', 'o', 'r', 'y', 0x02, 0x00, + 0x03, 'r', 'u', 'n', 0x00, 0x01, + 0x03, 'n', 'o', 'p', 0x00, 0x02, + 0x0a, 0x10, 0x02, + 0x0b, 0x00, 0x10, 0x00, 0x41, 0x00, 0x41, 0x01, 0x36, 0x02, 0x00, 0x0b, // run + 0x02, 0x00, 0x0b, // nop +} + +// The sequence that crashed in CI: a call's context ends during a host +// import, the import re-enters the guest, and wazero frees the memory in +// that nested call while the outer guest frame is still live and about to +// store. The memory must survive until the outer call has returned; an +// unmapped store here is a fault in compiled code that takes the process +// down, so this test cannot fail gently. +func TestMemoryOutlivesACallClosedDuringAHostImport(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + alloc := guest.NewAllocator(guest.BestEffort) + rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithCloseOnContextDone(true)) + defer rt.Close(context.Background()) + var freedDuringImport, nestedFailed bool + _, err := rt.NewHostModuleBuilder("env").NewFunctionBuilder(). + WithFunc(func(ctx context.Context, m api.Module) { + cancel() + _, nested := m.ExportedFunction("nop").Call(ctx) + nestedFailed = nested != nil + freedDuringImport = alloc.IsFreed() + }).Export("h").Instantiate(ctx) + if err != nil { + t.Fatal(err) + } + mod, err := rt.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, alloc), reentrantProbe, wazero.NewModuleConfig()) + if err != nil { + t.Fatalf("instantiating reentrant probe: %v", err) + } + alloc.Enter() + _, err = mod.ExportedFunction("run").Call(ctx) + alloc.Exit() + var exit *sys.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != sys.ExitCodeContextCanceled { + t.Fatalf("run: %v, want the cancellation exit", err) + } + if !nestedFailed { + t.Fatal("the nested call did not see the closed module") + } + if freedDuringImport { + t.Fatal("memory freed while the guest was suspended in a host import") + } + if !alloc.IsFreed() { + t.Fatal("memory not freed once the outer call returned") + } +} + +// The status table decodes to the shared sentinels, and an unknown code +// is an internal failure that keeps the number. +func TestStatusDecodesToTheSharedSentinels(t *testing.T) { + want := map[uint32]error{ + guest.StatusAuth: guest.ErrAuthentication, + guest.StatusEncoding: guest.ErrEncoding, + guest.StatusState: guest.ErrState, + guest.StatusInternal: guest.ErrInternal, + guest.StatusKMSUnauthorized: guest.ErrUnauthorized, + guest.StatusKMSForbidden: guest.ErrForbidden, + guest.StatusKMSNotFound: guest.ErrNotFound, + guest.StatusKMSConflict: guest.ErrConflict, + guest.StatusKMSTransport: guest.ErrTransport, + guest.StatusKMSOther: guest.ErrKMS, + guest.StatusTerm: guest.ErrTerm, + guest.StatusForeignKeyset: guest.ErrForeignKeyset, + guest.StatusProfileIO: guest.ErrProfileIO, + guest.StatusProfileJSON: guest.ErrProfileJSON, + guest.StatusProfileNotFound: guest.ErrProfileNotFound, + guest.StatusProfileInvalidFilename: guest.ErrInvalidFilename, + guest.StatusProfileNoCurrentWorkspace: guest.ErrNoCurrentWorkspace, + guest.StatusProfileInvalidWorkspaceID: guest.ErrInvalidWorkspaceID, + guest.StatusProfileWorkspaceNotFound: guest.ErrWorkspaceNotFound, + guest.StatusAuthInvalidGrant: guest.ErrAuthInvalidGrant, + guest.StatusAuthInvalidClient: guest.ErrAuthInvalidClient, + guest.StatusAuthUsageLimit: guest.ErrAuthUsageLimit, + guest.StatusAuthNotAuthenticated: guest.ErrAuthNotAuthenticated, + guest.StatusAuthTransport: guest.ErrAuthTransport, + guest.StatusAuthConfig: guest.ErrAuthConfig, + guest.StatusAuthOther: guest.ErrAuthOther, + guest.StatusAuthRefreshRequired: guest.ErrAuthRefreshRequired, + } + for code, sentinel := range want { + if got := guest.StatusError(code); got != sentinel { + t.Errorf("status %d decoded to %v, want %v", code, got, sentinel) + } + } + unknown := guest.StatusError(99) + if !errors.Is(unknown, guest.ErrInternal) || !strings.Contains(unknown.Error(), "99") { + t.Errorf("an unknown status decoded to %v; want ErrInternal naming the code", unknown) + } + if _, _, err := guest.PackedResult(uint64(guest.StatusEncoding)); err != guest.ErrEncoding { + t.Errorf("a packed status decoded to %v, want ErrEncoding", err) + } + if ptr, n, err := guest.PackedResult(uint64(0x1234)<<32 | 7); err != nil || ptr != 0x1234 || n != 7 { + t.Errorf("a packed buffer decoded to (%#x, %d, %v)", ptr, n, err) + } +} + +// The client key never prints its bytes — as a pointer, as a value, or +// inside a struct held either way, under any verb — and is empty once +// wiped. +func TestClientKeyIsOpaqueAndWipes(t *testing.T) { + material := []byte("key material that must not print") + key := guest.NewClientKey(material) + type holder struct { + ByPointer *guest.ClientKey + ByValue guest.ClientKey + } + subjects := map[string]any{ + "pointer": key, + "value": *key, + "struct": holder{ByPointer: key, ByValue: *key}, + "pointer to struct": &holder{ByPointer: key, ByValue: *key}, + } + // %d and %x reach a struct's fields without asking a Stringer; only a + // Formatter answers for them. + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%X", "%d"} { + for name, subject := range subjects { + out := fmt.Sprintf(verb, subject) + if strings.Contains(out, "material") || strings.Contains(out, "6d6174657269616c") || strings.Contains(out, "109 97 116") { + t.Errorf("%s of the %s printed the key: %q", verb, name, out) + } + } + } + if fmt.Sprint(key) != "ClientKey(***)" || key.String() != "ClientKey(***)" || key.GoString() != "ClientKey(***)" { + t.Errorf("the redaction is not the documented one: %s", key) + } + if string(guest.KeyBytes(key)) != "key material that must not print" { + t.Fatal("KeyBytes did not return the material") + } + key.Wipe() + if !key.IsZero() || guest.KeyBytes(key) != nil { + t.Fatal("a wiped key still holds material") + } + for _, b := range material { + if b != 0 { + t.Fatal("the caller's slice was not wiped") + } + } + key.Wipe() // a second wipe is a no-op + var none *guest.ClientKey + if !none.IsZero() || guest.KeyBytes(none) != nil { + t.Fatal("a nil key is not the empty key") + } + if out := fmt.Sprint(none); out != "<nil>" && out != "ClientKey(***)" { + t.Fatalf("a nil key printed %q", out) + } +} diff --git a/languages/golang/internal/guest/memory_unix.go b/languages/golang/internal/guest/memory_unix.go new file mode 100644 index 000000000..fb0413b15 --- /dev/null +++ b/languages/golang/internal/guest/memory_unix.go @@ -0,0 +1,51 @@ +//go:build unix + +package guest + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +// The Unix primitives behind mappedMemory: an anonymous private mapping +// reserved PROT_NONE so it costs address space only, committed with +// mprotect, locked with mlock, released with munmap. + +func reserveRange(max int) ([]byte, error) { + return unix.Mmap(-1, 0, max, unix.PROT_NONE, unix.MAP_PRIVATE|unix.MAP_ANON|reserveFlags) +} + +func commitRange(b []byte) error { + return unix.Mprotect(b, unix.PROT_READ|unix.PROT_WRITE) +} + +func decommitRange(b []byte) { + _ = unix.Mprotect(b, unix.PROT_NONE) +} + +// lockRange pins a committed range in RAM. The refusal names the limit +// that caused it and how to raise it, so an operator reading the error +// has the fix in hand. +func lockRange(b []byte) error { + if err := unix.Mlock(b); err != nil { + return fmt.Errorf("locking %s of guest memory: %w (%s; raise it with ulimit -l, a systemd LimitMEMLOCK=, or a pod securityContext)", byteCount(uint64(len(b))), err, memlockLimit()) + } + return nil +} + +func releaseRange(mapping []byte) { + _ = unix.Munmap(mapping) +} + +// memlockLimit describes RLIMIT_MEMLOCK for a lock refusal. +func memlockLimit() string { + var lim unix.Rlimit + if err := unix.Getrlimit(unix.RLIMIT_MEMLOCK, &lim); err != nil { + return "RLIMIT_MEMLOCK unknown" + } + if lim.Cur == unix.RLIM_INFINITY { + return "RLIMIT_MEMLOCK is unlimited" + } + return fmt.Sprintf("RLIMIT_MEMLOCK is %s", byteCount(uint64(lim.Cur))) +} diff --git a/languages/golang/internal/guest/memory_unix_other.go b/languages/golang/internal/guest/memory_unix_other.go new file mode 100644 index 000000000..6534cb23d --- /dev/null +++ b/languages/golang/internal/guest/memory_unix_other.go @@ -0,0 +1,12 @@ +//go:build unix && !linux + +package guest + +// Without MAP_NORESERVE the reservation may be charged against a strict +// overcommit setting on the BSDs; macOS has no such accounting. +const reserveFlags = 0 + +// excludeFromDumps has no equivalent outside Linux: macOS and the BSDs +// dump every mapping or none. macOS writes no core dumps by default; on a +// host that enables them the operator has chosen to capture memory. +func excludeFromDumps([]byte) error { return nil } diff --git a/languages/golang/internal/guest/memory_windows.go b/languages/golang/internal/guest/memory_windows.go new file mode 100644 index 000000000..d3f2df259 --- /dev/null +++ b/languages/golang/internal/guest/memory_windows.go @@ -0,0 +1,52 @@ +package guest + +import ( + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +// The Windows primitives behind mappedMemory: the declared maximum is +// reserved once (MEM_RESERVE), committed with MEM_COMMIT, locked with +// VirtualLock, released with MEM_RELEASE. Windows has no per-mapping dump +// exclusion. + +func reserveRange(max int) ([]byte, error) { + base, err := windows.VirtualAlloc(0, uintptr(max), windows.MEM_RESERVE, windows.PAGE_NOACCESS) + if err != nil { + return nil, err + } + // The reservation is not Go memory; going through unsafe.Add keeps + // the conversion within what vet's unsafeptr check accepts. + return unsafe.Slice((*byte)(unsafe.Add(unsafe.Pointer(nil), base)), max), nil //nolint:gosec // audited: wraps the VirtualAlloc reservation +} + +func address(b []byte) uintptr { return uintptr(unsafe.Pointer(unsafe.SliceData(b))) } //nolint:gosec // audited: address for VirtualAlloc/VirtualLock + +func commitRange(b []byte) error { + _, err := windows.VirtualAlloc(address(b), uintptr(len(b)), windows.MEM_COMMIT, windows.PAGE_READWRITE) + return err +} + +func decommitRange(b []byte) { + _ = windows.VirtualFree(address(b), uintptr(len(b)), windows.MEM_DECOMMIT) +} + +// lockRange pins a committed range in RAM. VirtualLock is bounded by the +// process's minimum working set, which defaults to a few hundred KiB, so +// the refusal says what to raise. +func lockRange(b []byte) error { + if err := windows.VirtualLock(address(b), uintptr(len(b))); err != nil { + return fmt.Errorf("locking %s of guest memory: %w (bounded by the process minimum working set; raise it with SetProcessWorkingSetSize before NewClient)", byteCount(uint64(len(b))), err) + } + return nil +} + +// releaseRange frees the whole reservation, dropping any lock with it. +func releaseRange(mapping []byte) { + _ = windows.VirtualFree(address(mapping), 0, windows.MEM_RELEASE) +} + +// excludeFromDumps has no Windows equivalent. +func excludeFromDumps([]byte) error { return nil } diff --git a/languages/golang/internal/guest/status.go b/languages/golang/internal/guest/status.go new file mode 100644 index 000000000..29b42ef55 --- /dev/null +++ b/languages/golang/internal/guest/status.go @@ -0,0 +1,112 @@ +package guest + +import "fmt" + +// Guest status codes: the low half of a packed error result, from the one +// table every guest reports through (packages/stack-guest-abi, status.rs). +// One numbering for both guests, never renumbered; a guest that needs a +// code of its own appends after the last one there, and here. +const ( + StatusAuth = 1 + StatusEncoding = 2 + StatusState = 3 + StatusInternal = 4 + StatusKMSUnauthorized = 5 + StatusKMSForbidden = 6 + StatusKMSNotFound = 7 + StatusKMSConflict = 8 + StatusKMSTransport = 9 + StatusKMSOther = 10 + StatusTerm = 11 + StatusForeignKeyset = 12 + // The credential guest's profile conditions. + StatusProfileIO = 13 + StatusProfileJSON = 14 + StatusProfileNotFound = 15 + StatusProfileInvalidFilename = 16 + StatusProfileNoCurrentWorkspace = 17 + StatusProfileInvalidWorkspaceID = 18 + StatusProfileWorkspaceNotFound = 19 + StatusAuthInvalidGrant = 20 + StatusAuthInvalidClient = 21 + StatusAuthUsageLimit = 22 + StatusAuthNotAuthenticated = 23 + StatusAuthTransport = 24 + StatusAuthConfig = 25 + StatusAuthOther = 26 + StatusAuthRefreshRequired = 27 +) + +// StatusError is the sentinel a guest status decodes to. A status this host +// does not know is still an internal failure; the code is kept so a +// guest/host version skew is diagnosable. +func StatusError(status uint32) error { + switch status { + case StatusAuth: + return ErrAuthentication + case StatusEncoding: + return ErrEncoding + case StatusState: + return ErrState + case StatusInternal: + return ErrInternal + case StatusKMSUnauthorized: + return ErrUnauthorized + case StatusKMSForbidden: + return ErrForbidden + case StatusKMSNotFound: + return ErrNotFound + case StatusKMSConflict: + return ErrConflict + case StatusKMSTransport: + return ErrTransport + case StatusKMSOther: + return ErrKMS + case StatusTerm: + return ErrTerm + case StatusForeignKeyset: + return ErrForeignKeyset + case StatusProfileIO: + return ErrProfileIO + case StatusProfileJSON: + return ErrProfileJSON + case StatusProfileNotFound: + return ErrProfileNotFound + case StatusProfileInvalidFilename: + return ErrInvalidFilename + case StatusProfileNoCurrentWorkspace: + return ErrNoCurrentWorkspace + case StatusProfileInvalidWorkspaceID: + return ErrInvalidWorkspaceID + case StatusProfileWorkspaceNotFound: + return ErrWorkspaceNotFound + case StatusAuthInvalidGrant: + return ErrAuthInvalidGrant + case StatusAuthInvalidClient: + return ErrAuthInvalidClient + case StatusAuthUsageLimit: + return ErrAuthUsageLimit + case StatusAuthNotAuthenticated: + return ErrAuthNotAuthenticated + case StatusAuthTransport: + return ErrAuthTransport + case StatusAuthConfig: + return ErrAuthConfig + case StatusAuthOther: + return ErrAuthOther + case StatusAuthRefreshRequired: + return ErrAuthRefreshRequired + default: + return fmt.Errorf("%w (unrecognized guest status %d)", ErrInternal, status) + } +} + +// PackedResult decodes a guest export's packed u64: a non-zero high half is +// an output pointer with the length in the low half; a zero high half +// carries a status code in the low half, returned as its sentinel. +func PackedResult(packed uint64) (ptr, length uint32, err error) { + if packed>>32 == 0 { + return 0, 0, StatusError(uint32(packed)) + } + return uint32(packed >> 32), uint32(packed), nil //nolint:gosec // splits the packed u64 into its two u32 halves +} diff --git a/languages/golang/internal/guest/testing.go b/languages/golang/internal/guest/testing.go new file mode 100644 index 000000000..ad9118554 --- /dev/null +++ b/languages/golang/internal/guest/testing.go @@ -0,0 +1,72 @@ +package guest + +// The testing seam: a way to refuse the guest's growth without a lock +// limit, so the bookkeeping of a refused growth — the allocator's and a +// client's — can be exercised on every host. Exported because the tests +// that need it live in the public packages, which cannot reach an +// allocator's backing; internal visibility keeps it out of any API. + +// Refusing stands in front of an allocator's backing and refuses, as +// Strict does, any commit past a size: nil buffer, the reason as the lock +// error, nothing admitted. Allowed, it delegates again. +type Refusing struct { + backend + past uint64 + reason error + refuse bool + refused int + // keep commits a refused growth all the same, unlocked, as BestEffort + // does: the reason becomes the allocator's lock error. + keep bool +} + +func (b *Refusing) commit(size uint64) ([]byte, error) { + if b.refuse && size > b.past { + b.refused++ + if b.keep { + buf, _ := b.backend.commit(size) + return buf, b.reason + } + return nil, b.reason + } + return b.backend.commit(size) +} + +// size reports the backing's, so a Refusing can front another. +func (b *Refusing) size() uint64 { return b.backend.(sized).size() } + +// Allow lifts the refusal: later growths go through to the real backing. +func (b *Refusing) Allow() { b.refuse = false } + +// Refused is how many growths were refused. +func (b *Refusing) Refused() int { return b.refused } + +// Reason is the error every refused growth reported. +func (b *Refusing) Reason() error { return b.reason } + +// RefuseGrowth puts a Refusing in front of alloc's backing, set to refuse +// any growth past what is committed now, with reason as the refusal. Call +// it between guest calls: the swap is made under the allocator's lock, but +// a commit already in flight on the guest's goroutine has the old backing. +func RefuseGrowth(alloc *Allocator, reason error) *Refusing { + alloc.mu.Lock() + defer alloc.mu.Unlock() + refusing := &Refusing{backend: alloc.backing, past: alloc.backing.(sized).size(), reason: reason, refuse: true} + alloc.backing = refusing + return refusing +} + +// UnlockGrowth is RefuseGrowth as a BestEffort allocator meets a refused +// lock: the growth goes through, and its range is reported unlocked with +// reason, from then on, as LockError. It forces the report a lock limit +// would give, on any host. +func UnlockGrowth(alloc *Allocator, reason error) *Refusing { + refusing := RefuseGrowth(alloc, reason) + refusing.keep = true + return refusing +} + +// sized is what the seam needs of a backing to know where it stands. Each +// backing implements it beside its own definition, under that file's +// build constraint, so this file builds on every platform. +type sized interface{ size() uint64 } diff --git a/languages/golang/internal/guesttest/memlock_other.go b/languages/golang/internal/guesttest/memlock_other.go new file mode 100644 index 000000000..2762a8276 --- /dev/null +++ b/languages/golang/internal/guesttest/memlock_other.go @@ -0,0 +1,8 @@ +//go:build !unix + +package guesttest + +import "errors" + +// SetMemlockLimit has nothing to lower where there is no RLIMIT_MEMLOCK. +func SetMemlockLimit(uint64) error { return errors.New("no RLIMIT_MEMLOCK on this platform") } diff --git a/languages/golang/internal/guesttest/memlock_unix.go b/languages/golang/internal/guesttest/memlock_unix.go new file mode 100644 index 000000000..c5fd820bc --- /dev/null +++ b/languages/golang/internal/guesttest/memlock_unix.go @@ -0,0 +1,18 @@ +//go:build unix + +package guesttest + +import "golang.org/x/sys/unix" + +// SetMemlockLimit lowers RLIMIT_MEMLOCK to n bytes for this process. Only +// a child test process calls it. +func SetMemlockLimit(n uint64) error { + var lim unix.Rlimit + setRlim(&lim.Cur, n) + setRlim(&lim.Max, n) + return unix.Setrlimit(unix.RLIMIT_MEMLOCK, &lim) +} + +// setRlim assigns a limit whatever width the platform gives the field +// (unsigned on Linux and Darwin, signed on the BSDs). +func setRlim[T ~int64 | ~uint64](field *T, n uint64) { *field = T(n) } diff --git a/languages/golang/internal/guesttest/probe.go b/languages/golang/internal/guesttest/probe.go new file mode 100644 index 000000000..23060f077 --- /dev/null +++ b/languages/golang/internal/guesttest/probe.go @@ -0,0 +1,152 @@ +// Package guesttest holds what the tests of the guest packages share: a +// hand-assembled probe module that exercises the memory allocator without +// a guest, the lock-limit machinery those tests need, and the skip rules +// for hosts that cannot run them. Internal, and imported only by _test +// files, so nothing here is API. +package guesttest + +import ( + "context" + "os" + "os/exec" + "runtime" + "strings" + "testing" + "unsafe" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/experimental" +) + +// GrowProbe is a hand-assembled module with one page of memory and one +// export that grows it, so the allocator can be exercised without a guest: +// +// (module +// (memory (export "memory") 1) +// (func (export "grow") (param i32) (result i32) +// local.get 0 memory.grow)) +// +// Like the guests, it declares no maximum, so wazero asks the allocator for +// wasm's 4 GiB default. +var GrowProbe = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic, version + 0x01, 0x06, 0x01, 0x60, 0x01, 0x7f, 0x01, 0x7f, // type: (i32) -> i32 + 0x03, 0x02, 0x01, 0x00, // function: one, of type 0 + 0x05, 0x03, 0x01, 0x00, 0x01, // memory: one, min 1 page, no max + 0x07, 0x11, 0x02, // exports: two + 0x06, 'm', 'e', 'm', 'o', 'r', 'y', 0x02, 0x00, // "memory" = memory 0 + 0x04, 'g', 'r', 'o', 'w', 0x00, 0x00, // "grow" = func 0 + 0x0a, 0x08, 0x01, 0x06, 0x00, 0x20, 0x00, 0x40, 0x00, 0x0b, // code +} + +// WasmPage is the size of one wasm memory page. +const WasmPage = 64 * 1024 + +// ProbeMemory instantiates GrowProbe under alloc and returns the base +// address of its memory and a grow function reporting the old page count. +func ProbeMemory(t *testing.T, alloc *guest.Allocator) (base func() uintptr, grow func(pages uint32) (old uint32, ok bool), done func()) { + t.Helper() + ctx := context.Background() + rt := wazero.NewRuntime(ctx) + mod, err := rt.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, alloc), GrowProbe, wazero.NewModuleConfig()) + if err != nil { + _ = rt.Close(ctx) + t.Fatalf("instantiating grow probe: %v", err) + } + base = func() uintptr { return MemoryBase(t, mod.Memory()) } + grow = func(pages uint32) (uint32, bool) { + res, err := mod.ExportedFunction("grow").Call(ctx, uint64(pages)) + if err != nil { + t.Fatalf("grow: %v", err) + } + return uint32(res[0]), int32(res[0]) != -1 + } + done = func() { _ = rt.Close(ctx) } + return base, grow, done +} + +// MemoryBase is the host address of a module memory's first byte. Read +// returns a view into the buffer, not a copy. +func MemoryBase(t *testing.T, mem api.Memory) uintptr { + t.Helper() + view, ok := mem.Read(0, 1) + if !ok { + t.Fatal("reading guest memory") + } + return uintptr(unsafe.Pointer(unsafe.SliceData(view))) +} + +// HostReserves reports whether this host can back a guest with a +// reservation at all. Where it cannot (a 32-bit host asked for wasm's +// 4 GiB default, which CI exercises on purpose under GOARCH=386) the heap +// fallback is in use, no lock is possible, and the tests of a lock granted +// or refused have nothing to test: they skip, whatever RequireLock says. +func HostReserves(t *testing.T) bool { + t.Helper() + probe := guest.NewAllocator(guest.BestEffort) + _, _, done := ProbeMemory(t, probe) + done() + return !probe.IsFallback() +} + +// RequireLock is set in CI, where RLIMIT_MEMLOCK has been raised, so the +// lock cannot quietly go untested. Without it a refused lock is reported +// and the assertion skipped: a developer laptop's default limit is not a +// bug in these packages. +const RequireLock = "STACKENCRYPT_TESTS_REQUIRE_LOCK" + +// LockOrSkip continues if alloc's memory is locked, skips if the host +// refused the lock (or has no reservation to lock), and fails the skip +// when RequireLock says the host was meant to grant it. +func LockOrSkip(t *testing.T, alloc *guest.Allocator) { + t.Helper() + err := alloc.LockError() + if err == nil { + return + } + if alloc.IsFallback() { + // Not a refused lock: there was no reservation to lock (a 32-bit + // host), which CI exercises on purpose under GOARCH=386. + t.Skipf("heap fallback in use on this host: %v", err) + } + SkipUnlessLockRequired(t, "the lock was refused", err) +} + +// SkipUnlessLockRequired skips a test the host cannot run — what says why, +// detail is the refusal or the child's output — unless RequireLock says the +// host was meant to, in which case it fails. +func SkipUnlessLockRequired(t *testing.T, what string, detail any) { + t.Helper() + if os.Getenv(RequireLock) != "" { + t.Fatalf("%s is set and %s:\n%v", RequireLock, what, detail) + } + t.Skipf("%s on this host:\n%v", what, detail) +} + +// InChild re-runs the calling test in a child process, for tests that +// lower RLIMIT_MEMLOCK: the change is process-wide and irreversible for a +// non-root process. It returns true in the child, which prints "case ok" +// when done or "case skipped: <why>" when the host cannot provoke the +// condition; the parent judges that output and returns false. +func InChild(t *testing.T) bool { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("no RLIMIT_MEMLOCK on Windows") + } + const child = "STACKENCRYPT_TEST_CHILD" + if os.Getenv(child) != "" { + return true + } + cmd := exec.Command(os.Args[0], "-test.run=^"+t.Name()+"$", "-test.v") + cmd.Env = append(os.Environ(), child+"=1") + out, err := cmd.CombinedOutput() + switch { + case strings.Contains(string(out), "case skipped:"): + SkipUnlessLockRequired(t, "the refusal could not be provoked", string(out)) + case err != nil || !strings.Contains(string(out), "case ok"): + t.Fatalf("child failed: %v\n%s", err, out) + } + return false +} diff --git a/languages/golang/internal/guesttest/smaps_linux.go b/languages/golang/internal/guesttest/smaps_linux.go new file mode 100644 index 000000000..dcde0a62b --- /dev/null +++ b/languages/golang/internal/guesttest/smaps_linux.go @@ -0,0 +1,111 @@ +package guesttest + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/internal/guest" +) + +// The protection is observable from the kernel's side, in +// /proc/self/smaps. The dump exclusion has no limit, so it is asserted on +// every mapping the allocator reserves; the lock only where the host +// granted it. + +// AssertMappingProtected checks the smaps entry containing addr: dd +// (MADV_DONTDUMP) whenever there is a reservation at all, and, where the +// lock was granted, lo (mlock) with the whole resident range locked. A +// refused lock skips the second half after the first has run; a failed +// first half fails the test whatever the second does. +func AssertMappingProtected(t *testing.T, alloc *guest.Allocator, addr uintptr) { + t.Helper() + if alloc.IsFallback() { + t.Skipf("heap fallback in use on this host: %v", alloc.LockError()) + } + mapping, err := smapsEntry(addr) + if err != nil { + t.Fatal(err) + } + if !mapping.hasFlag("dd") { + t.Errorf("VmFlags = %q, want dd (MADV_DONTDUMP)", mapping.vmFlags) + } + LockOrSkip(t, alloc) + if !mapping.hasFlag("lo") { + t.Errorf("VmFlags = %q, want lo (mlock)", mapping.vmFlags) + } + if mapping.lockedKB == 0 || mapping.lockedKB != mapping.rssKB { + t.Errorf("Locked = %d kB, Rss = %d kB: the committed range is not fully locked", mapping.lockedKB, mapping.rssKB) + } +} + +type smapsMapping struct { + rssKB, lockedKB uint64 + vmFlags string +} + +func (m smapsMapping) hasFlag(flag string) bool { + return strings.Contains(" "+m.vmFlags+" ", " "+flag+" ") +} + +// smapsEntry finds the /proc/self/smaps mapping containing addr. +func smapsEntry(addr uintptr) (smapsMapping, error) { + f, err := os.Open("/proc/self/smaps") + if err != nil { + return smapsMapping{}, err + } + defer f.Close() + var cur smapsMapping + inside := false + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if lo, hi, ok := smapsRange(line); ok { + if inside { + return cur, nil + } + inside = addr >= lo && addr < hi + cur = smapsMapping{} + continue + } + if !inside { + continue + } + key, value, _ := strings.Cut(line, ":") + value = strings.TrimSpace(value) + switch key { + case "Rss": + cur.rssKB = smapsKB(value) + case "Locked": + cur.lockedKB = smapsKB(value) + case "VmFlags": + cur.vmFlags = value + } + } + if inside { + return cur, nil + } + return smapsMapping{}, fmt.Errorf("no smaps mapping contains %#x", addr) +} + +func smapsRange(line string) (lo, hi uintptr, ok bool) { + head, _, _ := strings.Cut(line, " ") + a, b, found := strings.Cut(head, "-") + if !found { + return 0, 0, false + } + l, err1 := strconv.ParseUint(a, 16, 64) + h, err2 := strconv.ParseUint(b, 16, 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return uintptr(l), uintptr(h), true +} + +func smapsKB(value string) uint64 { + n, _ := strconv.ParseUint(strings.TrimSuffix(value, " kB"), 10, 64) + return n +} diff --git a/languages/golang/stackauth/README.md b/languages/golang/stackauth/README.md new file mode 100644 index 000000000..8f411e3d3 --- /dev/null +++ b/languages/golang/stackauth/README.md @@ -0,0 +1,117 @@ +# stackauth + +The Go binding of the developer profile — the directory `stash auth login` +writes — read through the `stack-profile` Rust crate running inside a WASI +guest under [wazero], with `CGO_ENABLED=0`. It is the credential half of +the Go SDK: it hands a [`stackencrypt`](../stackencrypt) client its client +key and its bearer token without either package re-deriving the profile's +layout, and without either importing the other. + +The guest also runs the `stack-auth` strategies: access key, device session, +OIDC federation, and automatic selection. Go supplies HTTP and holds the +cross-process refresh lock for device sessions. + +[wazero]: https://wazero.io +[ADR-0005]: ../../../packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md + +## Use + +Most applications never call this package directly: a `stackencrypt` +client built with `NewClient(ctx)` and no options resolves its credentials with +`stackencrypt.AutoCredentials`, which reads the environment first and then +the profile, through this package. Use it directly to take the profile +apart yourself: + +```go +import ( + "context" + + "github.com/cipherstash/stack/languages/golang/stackauth" + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +func run(ctx context.Context) error { + profile, err := stackauth.Resolve(ctx) // CS_CONFIG_PATH, else ~/.cipherstash + if err != nil { + return err + } + defer profile.Close() + + workspace, err := profile.CurrentWorkspaceStore(ctx) + if err != nil { + return err // stackauth.ErrNoCurrentWorkspace: run `stash auth login` + } + clientID, clientKey, err := workspace.SecretKey(ctx) + if err != nil { + return err + } + source, err := workspace.DeviceSession(ctx) // refreshes under the CLI's lock + if err != nil { + return err + } + defer source.Close() + client, err := stackencrypt.NewClient(ctx, + // The key is consumed and wiped by NewClient. + stackencrypt.WithCredentials(stackencrypt.NewCredentials(clientID, clientKey, source)), + ) + if err != nil { + return err + } + defer client.Close() + // ... + return nil +} +``` + +`stackauth.ClientKey` and `stackencrypt.ClientKey` are one type, so the +key goes straight from the profile into the credentials. The profile and +the strategy are the caller's: the client asks the strategy for a token on +every request but never closes it, so both stay open until the client is +closed (the deferred calls above run in that order). + +A stackencrypt client takes its token only from a strategy, never a raw +string: a raw token cannot be refreshed when it expires, and would bypass +the cross-process lock a device-session refresh holds with the `stash` CLI +(the IdP revokes a whole refresh-token chain when one is used twice). +`workspace.Token(ctx)` still reads the stored token, for inspection. + +With no profile directory at all (CI, a container, a server authenticating +by federation), `stackauth.OpenWithoutProfile(ctx)` runs the guest with +nothing mounted: the access-key and OIDC strategies work, and every profile +read is `ErrNoProfile`. + +`profile.AccessKey(ctx, crn, key)`, `profile.OIDC(ctx, crn, provider)`, and +`profile.Auto(ctx)` also return strategies that `stackencrypt.NewCredentials` +takes. `Auto` checks `CS_CLIENT_ACCESS_KEY` and +`CS_WORKSPACE_CRN` first, then the current workspace's stored device session. +The OIDC provider is a one-method `Token(context.Context) (string, error)` +interface. Use `stackauth.OAuth2TokenSource(source)` to adapt a +`golang.org/x/oauth2.TokenSource`. `WithAuthBaseURL(url)` overrides service +discovery for local tests or a custom CTS host. + +## What the guest is given + +Exactly one directory, mounted read-write at a fixed guest path, and no +environment. It cannot name a path outside it: every path is built by the +Rust crate from the store's directory and a validated filename or +workspace id, and the mount itself is confined — a symlink inside the +profile that leads outside it is refused, for reads and for the one write, +rather than followed with the process's permissions as a plain directory +mount would. Files it creates are mode 0600. It takes no file lock (WASI +preview 1 has none); Go holds the same lock as the CLI across the device +session refresh call, on the path `ProfileStore.LockPath` names. A fresh +token is read without the lock; on refresh the guest re-reads +auth.json after acquisition and saves refreshed tokens before release. + +The crypto guest behind `stackencrypt` is not widened by this package +existing: it still has no filesystem and no environment. + +## Build + +``` +mise run wasm:auth-guest:build # the guest, with its import-surface gate +mise run go:test # the whole Go module, both packages +``` + +The guest module is embedded from `wasm/` and not committed; without it, +`Open` returns `ErrGuestNotBuilt` and the tests skip. diff --git a/languages/golang/stackauth/clientkey.go b/languages/golang/stackauth/clientkey.go new file mode 100644 index 000000000..30553833b --- /dev/null +++ b/languages/golang/stackauth/clientkey.go @@ -0,0 +1,12 @@ +package stackauth + +import "github.com/cipherstash/stack/languages/golang/internal/guest" + +// ClientKey is the ZeroKMS client key as [ProfileStore.SecretKey] reads it +// out of secretkey.json: opaque (it prints a redaction under every verb and +// hands its bytes to no caller) and wiped once consumed. It is the same +// type as stackencrypt.ClientKey, by identity, so a key read here goes +// straight into stackencrypt.NewCredentials. This package does not import +// stackencrypt: a binary that only wants the profile does not carry the +// crypto guest. (stackencrypt imports this one, for AutoCredentials.) +type ClientKey = guest.ClientKey diff --git a/languages/golang/stackauth/doc.go b/languages/golang/stackauth/doc.go new file mode 100644 index 000000000..46feff9b0 --- /dev/null +++ b/languages/golang/stackauth/doc.go @@ -0,0 +1,71 @@ +// Package stackauth is the Go binding of the developer profile: the +// directory `stash auth login` writes (~/.cipherstash, or CS_CONFIG_PATH), +// read through the stack-profile crate running unmodified inside a WASI +// guest under wazero (CGO_ENABLED=0), so the on-disk layout is never +// re-derived by hand in Go. +// +// # Shape +// +// A [ProfileStore] is one guest instance over one mounted directory. +// [Resolve] finds the profile directory the way the Rust crate does +// (CS_CONFIG_PATH, then ~/.cipherstash); [Open] takes one; +// [OpenWithoutProfile] mounts nothing, for the access-key and OIDC +// strategies where there is no profile. The guest is given that directory +// and nothing else: no environment, no other path, and no way out through a +// symlink inside it, which the mount refuses to follow. Authentication HTTP +// requests go through the Go host's transport import. Everything the napi +// binding of stack-profile exposes is a method here, named as in Rust: the +// current workspace ([ProfileStore.CurrentWorkspace], +// [ProfileStore.SetCurrentWorkspace], [ProfileStore.ClearCurrentWorkspace]), +// the workspaces on disk ([ProfileStore.ListWorkspaces]), a store scoped to +// one workspace ([ProfileStore.WorkspaceStore], +// [ProfileStore.CurrentWorkspaceStore]), and the typed reads of the files a +// workspace holds: [ProfileStore.SecretKey] hands out the ZeroKMS client key +// as the opaque [ClientKey] that stackencrypt.NewCredentials takes, +// [ProfileStore.Token] the stored access token, [ProfileStore.DeviceIdentity] +// the identity the CLI created. [ProfileStore.Close] releases the guest; +// stores scoped from it are closed with it. +// +// For authentication and refresh, use [ProfileStore.AccessKey], +// [ProfileStore.OIDC], [ProfileStore.DeviceSession], or [ProfileStore.Auto]. +// Each returns a [Strategy], which is what stackencrypt.NewCredentials takes +// for the bearer token: the only way a token reaches a stackencrypt client. +// A raw token — [ProfileStore.Token]'s included — cannot be refreshed when +// it expires, and would bypass the cross-process lock a device-session +// refresh holds with the CLI, so stackencrypt does not accept one. A +// strategy lives in its store's guest, and closing the store closes it. A +// stackencrypt client given one never closes the strategy or the store: +// both are the caller's, and stay open until the client is closed. +// [OAuth2TokenSource] adapts an existing golang.org/x/oauth2.TokenSource +// into the OIDC provider interface. +// +// # Why a second guest +// +// The crypto guest behind stackencrypt has no filesystem and no +// environment: a bug or compromise inside it cannot read credentials off +// disk. Mounting the profile into it would trade that away, and it is the +// module that handles plaintext and data keys. So the profile lives in its +// own module with its own, smaller blast radius: one directory of +// credentials. ADR-0005 in packages/stack-encrypt/docs/adr records the +// decision and the alternatives. +// +// # What the guest cannot do +// +// WASI preview 1 has no file locking, so the guest takes none. Go holds the +// same cross-process lock as the Rust CLI, on the path [ProfileStore.LockPath] +// names, across each device-session call. The guest re-reads auth.json after +// the lock and saves a rotated token before Go releases it. Creating a device +// identity is native-only in the crate and +// CLI territory; this package only reads one. Files the guest creates are +// mode 0600, which is wazero's create mode rather than the crate's own +// (skipped on wasm32), so a test pins it. +// +// # Memory +// +// The guest's memory holds the client key and the token while a read is +// in flight. It is supplied the way stackencrypt's is — reserved once so it +// never moves, locked in RAM and excluded from core dumps where the +// platform allows, wiped before release, none of it depending on Close +// running — and [ProfileStore.MemoryLocked] reports whether the lock was +// granted. [RequireLockedMemory] makes a refused lock an error from Open. +package stackauth diff --git a/languages/golang/stackauth/errors.go b/languages/golang/stackauth/errors.go new file mode 100644 index 000000000..b5a2746f2 --- /dev/null +++ b/languages/golang/stackauth/errors.go @@ -0,0 +1,64 @@ +package stackauth + +import ( + "errors" + + "github.com/cipherstash/stack/languages/golang/internal/guest" +) + +// Failure kinds the profile reports. The guest reports a status code from +// the one table every guest shares, so these are the sentinels of the +// shared decoder exposed under this package's names; an error from +// stackencrypt of the same kind is the same value. +var ( + // ErrNotFound is a profile file that does not exist in the store asked: + // no secretkey.json, auth.json or device.json there. For the workspace's + // files that means nothing has logged in to it on this machine. + ErrNotFound = guest.ErrProfileNotFound + // ErrInvalid is a profile file that is not the JSON its type expects. + ErrInvalid = guest.ErrProfileJSON + // ErrIO is a profile file that could not be read or written. + ErrIO = guest.ErrProfileIO + // ErrInvalidFilename is a filename the store refuses: empty, absolute, + // or naming a path. + ErrInvalidFilename = guest.ErrInvalidFilename + // ErrNoCurrentWorkspace is a workspace-scoped operation with no current + // workspace set: run `stash auth login`. + ErrNoCurrentWorkspace = guest.ErrNoCurrentWorkspace + // ErrInvalidWorkspaceID is a workspace id that is not sixteen base32 + // characters. Refused before any path is built from it. + ErrInvalidWorkspaceID = guest.ErrInvalidWorkspaceID + // ErrWorkspaceNotFound is a workspace with no directory under + // workspaces/: nothing has logged in to it on this machine. + ErrWorkspaceNotFound = guest.ErrWorkspaceNotFound + // ErrEncoding is an input the guest refused: a directory, id or filename + // that is not UTF-8. + ErrEncoding = guest.ErrEncoding + // ErrState is a call on a store that has been closed. + ErrState = guest.ErrState + // ErrInternal is a guest panic or any other unexpected guest failure. + ErrInternal = guest.ErrInternal + // ErrInvalidGrant is an OAuth refresh grant the auth server rejected. + ErrInvalidGrant = guest.ErrAuthInvalidGrant + // ErrInvalidClient is a client credential the auth server rejected. + ErrInvalidClient = guest.ErrAuthInvalidClient + // ErrUsageLimit is an account blocked by its usage allowance. + ErrUsageLimit = guest.ErrAuthUsageLimit + // ErrNotAuthenticated means no usable auth credential is available. + ErrNotAuthenticated = guest.ErrAuthNotAuthenticated + // ErrAuthTransport is a failed auth HTTP exchange or response read. + ErrAuthTransport = guest.ErrAuthTransport + // ErrAuthConfig is invalid auth configuration or token data. + ErrAuthConfig = guest.ErrAuthConfig + // ErrAuthOther is an auth failure outside the actionable categories above. + ErrAuthOther = guest.ErrAuthOther + // ErrMemoryLock is guest memory that could not be locked in RAM (or, on + // Linux, excluded from core dumps). Open returns it under + // [RequireLockedMemory]; otherwise [ProfileStore.MemoryLockError] + // reports it and the store works on with unlocked memory. + ErrMemoryLock = guest.ErrMemoryLock + + // ErrNoProfile is a profile directory that does not exist: nothing has + // logged in on this machine, or CS_CONFIG_PATH names the wrong place. + ErrNoProfile = errors.New("stackauth: no profile directory; run `stash auth login`") +) diff --git a/languages/golang/stackauth/guest.go b/languages/golang/stackauth/guest.go new file mode 100644 index 000000000..dc7239267 --- /dev/null +++ b/languages/golang/stackauth/guest.go @@ -0,0 +1,201 @@ +package stackauth + +import ( + "context" + "crypto/rand" + "embed" + "errors" + "fmt" + "net/http" + "sync" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/experimental" + "github.com/tetratelabs/wazero/experimental/sysfs" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +// The guest module is a build artefact of the Rust crate in ./guest, +// copied here by `mise run wasm:auth-guest:build`. It is embedded as a +// directory so the package compiles without it; Open reports its absence. +// +//go:embed wasm +var guestFS embed.FS + +const guestPath = "wasm/stack_auth_guest.wasm" + +// guestRoot is where the guest sees the profile directory. The one mount +// the guest is given lands here, and every store directory the package +// names is under it. Pinned against the guest's own constant by its tests. +const guestRoot = "/profile" + +// ErrGuestNotBuilt is returned by Open when no guest module is embedded and +// none was supplied with [WithGuest]. +var ErrGuestNotBuilt = errors.New("stackauth: guest module not built — run `mise run wasm:auth-guest:build`") + +func embeddedGuest() ([]byte, error) { + wasm, err := guestFS.ReadFile(guestPath) + if err != nil { + return nil, ErrGuestNotBuilt + } + return wasm, nil +} + +// One shared compilation cache: only the first instantiation of a given +// module in the process compiles it. Every store still owns its own +// runtime and instance. +var ( + cacheOnce sync.Once + sharedCache wazero.CompilationCache +) + +func compilationCache() wazero.CompilationCache { + cacheOnce.Do(func() { sharedCache = wazero.NewCompilationCache() }) + return sharedCache +} + +// instance is one instantiated guest over one mounted directory, with its +// exports resolved. It is the unsynchronised half of a store; the root +// serialises access. +type instance struct { + runtime wazero.Runtime + module api.Module + mem *guest.Allocator + exports guest.Exports + // mount is the one directory the guest sees, confined to itself. + mount *confinedFS + transport *authTransport + + shutdown api.Function + currentWorkspace, setCurrentWorkspace, clearCurrentWorkspace api.Function + listWorkspaces, workspaceDir, lockPath api.Function + secretKey, token, hasToken, deviceIdentity api.Function + authNew, authValidateCRN, authToken, authRefresh, authFree api.Function +} + +// guestModuleConfig is the module configuration every guest instance runs +// under: the one directory mount, confined to itself (see confinedFS), and +// nothing else that grants a capability. No environment: the guest is told +// its directory, it never looks one up. random_get is wired to crypto/rand +// because wazero's default is a fixed seed: the Rust runtime draws through +// it (its hash maps are seeded from it, for one), and nothing a guest does +// should be predictable across instances. The clocks are the system's for +// the same reason they are in stackencrypt: a deterministic default is the +// wrong default for anything that reads time. Each is pinned by a test. +// +// A nil mount is a guest with no directory at all ([OpenWithoutProfile]): +// no filesystem is configured, so there is nothing to mount or confine. +func guestModuleConfig(mount *confinedFS) wazero.ModuleConfig { + config := wazero.NewModuleConfig(). + WithName("stack_auth_guest"). + WithRandSource(rand.Reader). + WithSysNanotime(). + WithSysWalltime() + if mount == nil { + return config + } + return config.WithFSConfig(wazero.NewFSConfig().(sysfs.FSConfig).WithSysFSMount(mount, guestRoot)) +} + +// newInstance instantiates wasm with hostDir mounted at guestRoot and its +// linear memory from the guest packages' allocator. An empty hostDir mounts +// nothing. Under the strict policy, memory that cannot be locked fails +// instantiation with ErrMemoryLock. +func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest.LockPolicy, rt http.RoundTripper) (*instance, error) { + var mount *confinedFS + if hostDir != "" { + var err error + if mount, err = newConfinedFS(hostDir); err != nil { + return nil, fmt.Errorf("%w: %s: %w", ErrNoProfile, hostDir, err) + } + } + config := wazero.NewRuntimeConfig(). + WithCompilationCache(compilationCache()). + WithCloseOnContextDone(true) + runtime := wazero.NewRuntimeWithConfig(ctx, config) + fail := func(err error) (*instance, error) { + _ = runtime.Close(ctx) + if mount != nil { + _ = mount.Close() + } + return nil, err + } + if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + return fail(fmt.Errorf("stackauth: instantiating WASI: %w", err)) + } + transport := newAuthTransport(rt) + if err := transport.instantiate(ctx, runtime); err != nil { + return fail(fmt.Errorf("stackauth: instantiating host transport: %w", err)) + } + mem := guest.NewAllocator(policy) + // The guest is a reactor (cdylib): no _start. wazero runs _initialize + // when present, so guest code runs here too, and the memory must stay + // mapped until it returns, the same as around a call. + mem.Enter() + module, err := func() (api.Module, error) { + defer mem.Exit() + return runtime.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, mem), wasm, guestModuleConfig(mount)) + }() + if err != nil { + if g := mem.GrowthRefusal(); g.Refused != 0 { + return fail(fmt.Errorf("%w: %w", guest.MemoryLockError(g.Reason), err)) + } + return fail(fmt.Errorf("stackauth: instantiating guest: %w", err)) + } + if policy == guest.Strict { + if lerr := mem.LockError(); lerr != nil { + return fail(guest.MemoryLockError(lerr)) + } + } + inst := &instance{runtime: runtime, module: module, mem: mem, mount: mount, transport: transport} + exports := map[string]*api.Function{ + "se_alloc": &inst.exports.Alloc, + "se_dealloc": &inst.exports.Dealloc, + "sa_shutdown": &inst.shutdown, + "sa_current_workspace": &inst.currentWorkspace, + "sa_set_current_workspace": &inst.setCurrentWorkspace, + "sa_clear_current_workspace": &inst.clearCurrentWorkspace, + "sa_list_workspaces": &inst.listWorkspaces, + "sa_workspace_dir": &inst.workspaceDir, + "sa_lock_path": &inst.lockPath, + "sa_secret_key": &inst.secretKey, + "sa_token": &inst.token, + "sa_has_token": &inst.hasToken, + "sa_device_identity": &inst.deviceIdentity, + "sa_auth_new": &inst.authNew, + "sa_auth_validate_crn": &inst.authValidateCRN, + "sa_auth_token": &inst.authToken, + "sa_auth_refresh": &inst.authRefresh, + "sa_auth_free": &inst.authFree, + } + for name, slot := range exports { + if *slot = module.ExportedFunction(name); *slot == nil { + return fail(fmt.Errorf("stackauth: guest is missing export %s", name)) + } + } + return inst, nil +} + +// release runs the guest's shutdown — every buffer it still holds wiped — +// and closes the runtime, which frees the linear memory through the +// allocator's wipe, then the directory handle the mount holds. A module an +// interrupted call or a trap already closed cannot run sa_shutdown; the +// runtime close still wipes and frees its memory. +func (inst *instance) release() error { + ctx := context.Background() + if !inst.module.IsClosed() { + inst.mem.Enter() + _, _ = inst.shutdown.Call(ctx) + inst.mem.Exit() + } + err := inst.runtime.Close(ctx) + if inst.mount == nil { + return err + } + if cerr := inst.mount.Close(); err == nil { + err = cerr + } + return err +} diff --git a/languages/golang/stackauth/guest/.gitignore b/languages/golang/stackauth/guest/.gitignore new file mode 100644 index 000000000..32e28bc74 --- /dev/null +++ b/languages/golang/stackauth/guest/.gitignore @@ -0,0 +1,3 @@ +# Cargo output of this detached workspace; the built module is copied to +# ../wasm by `mise run wasm:auth-guest:build`. +/target diff --git a/languages/golang/stackauth/guest/Cargo.lock b/languages/golang/stackauth/guest/Cargo.lock new file mode 100644 index 000000000..106a30b75 --- /dev/null +++ b/languages/golang/stackauth/guest/Cargo.lock @@ -0,0 +1,2774 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +dependencies = [ + "serde", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cached" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.5", + "once_cell", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cipherstash-config" +version = "0.42.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d098935e395d7346d0cdc8cdf3ed9674ab03fa8b415e828d02e65c81836a73c" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-hex" +version = "1.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e59eef12462b0f9b0a3620219be5d639afd79fe39dff0a42c3997061f9298b4" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cts-common" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cb0f5ffa463e8facbe6ad78cfe925d132a051c6b1c9a5da2f3961296b7e632" +dependencies = [ + "arrayvec", + "base32", + "cached", + "chrono", + "derive_more", + "either", + "getrandom 0.4.3", + "miette", + "nom", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "utoipa", + "uuid", + "vitaminc", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dummy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fake" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" +dependencies = [ + "deunicode", + "dummy", + "rand 0.8.8", + "uuid", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix 0.38.44", + "windows-targets", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mutants" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.42.3" +dependencies = [ + "aquamarine", + "base64", + "cts-common", + "jsonwebtoken", + "miette", + "open", + "serde", + "serde_json", + "serde_urlencoded", + "stack-profile", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "web-time", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-auth-guest" +version = "0.0.0" +dependencies = [ + "cts-common", + "futures", + "serde", + "serde_json", + "stack-auth", + "stack-guest-abi", + "stack-profile", + "tempfile", + "url", + "vitaminc-aead-value", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "stack-guest-abi" +version = "0.0.0" +dependencies = [ + "thiserror 1.0.69", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "stack-profile" +version = "0.42.3" +dependencies = [ + "dirs", + "gethostname", + "serde", + "serde_json", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "url", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "md-5", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" +dependencies = [ + "darling 0.23.0", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vitaminc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +dependencies = [ + "vitaminc-aead", + "vitaminc-context", + "vitaminc-encrypt", + "vitaminc-protected", + "vitaminc-random", + "vitaminc-traits", +] + +[[package]] +name = "vitaminc-aead" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +dependencies = [ + "bytes", + "serde", + "vitaminc-aead-derive", + "vitaminc-context", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "vitaminc-aead-value" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +dependencies = [ + "vitaminc-aead", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-context" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +dependencies = [ + "mutants", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.20", + "vitaminc-protected-derive", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "vitaminc-random" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand 0.10.3", + "thiserror 2.0.20", + "vitaminc-protected", + "vitaminc-random-derives", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "vitaminc-traits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.20", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerokms-protocol" +version = "0.12.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c28e88315a5109d0a1e7ee4b7b4b8776a0bff5f5b139ae83960a3debe84e92e" +dependencies = [ + "base64", + "cipherstash-config", + "const-hex", + "cts-common", + "fake", + "getrandom 0.2.17", + "opaque-debug", + "rand 0.8.8", + "serde", + "static_assertions", + "thiserror 1.0.69", + "utoipa", + "uuid", + "validator", + "zeroize", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/languages/golang/stackauth/guest/Cargo.toml b/languages/golang/stackauth/guest/Cargo.toml new file mode 100644 index 000000000..8bb48dba1 --- /dev/null +++ b/languages/golang/stackauth/guest/Cargo.toml @@ -0,0 +1,62 @@ +# The credential guest: `stack-profile` and `stack-auth` strategies under +# WASI/wazero, embedded by the Go package +# `stackauth` one directory up. ADR-0005 in packages/stack-encrypt/docs/adr. +# +# A second module rather than the crypto guest widened: this one is given a +# directory of credentials, and that one handles plaintext and data keys. +# Deliberately a standalone workspace, like the crypto guest: it targets +# wasm32-wasip1 and is consumed as a .wasm artifact, so its wasm-only +# profile and target must not leak into workspace builds. +# +# Build: mise run wasm:auth-guest:build (or: cargo build --target wasm32-wasip1 --release) +[package] +name = "stack-auth-guest" +description = "WASI guest exposing stack-profile and stack-auth to Go/wazero" +version = "0.0.0" +edition = "2021" +publish = false + +[workspace] + +[lib] +# cdylib: the .wasm guest module. rlib: lets the ops/status modules +# unit-test natively (`cargo test` here, no wasm toolchain needed). +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +futures = "0.3" +cts-common = { version = "=0.43.0", default-features = false } +url = "2" +# Strategies and `Token`, the typed shape of `auth.json`, with default +# features off: no reqwest, no native TLS. +stack-auth = { path = "../../../../packages/stack-auth", default-features = false } +# The ABI every guest under bindings/go shares: the allocator and buffer +# registry (and with them the `se_alloc`/`se_dealloc` exports), the +# packed-result helpers and the status table. This crate defines only the +# exports that are its own. +stack-guest-abi = { path = "../../../../packages/stack-guest-abi" } +# The profile directory's API. It builds for wasm32-wasip1 (CIP-4114): the +# hostname and the process id are native-only there, and creating a device +# identity is CLI territory. +stack-profile = { path = "../../../../packages/stack-profile" } +# The FFI codec for the structured results (a list of workspace ids, the +# fields of a token): the one codec every binding carries. Same vitaminc +# version the crypto guest builds against. +vitaminc-aead-value = "0.5.0" +# The deserialized form of `secretkey.json` is wiped when it drops. +zeroize = { version = "1", features = ["derive"] } + +[dev-dependencies] +serde_json = "1" +tempfile = "3" +# `Controlled::risky_ref`, to read a codec value back in the tests. +vitaminc-protected = "0.5.0" + +[profile.release] +# Smaller .wasm; the guest is IO-bound on the FFI copy and the file reads, +# not on codegen. +opt-level = "s" +lto = true +strip = true diff --git a/languages/golang/stackauth/guest/src/abi.rs b/languages/golang/stackauth/guest/src/abi.rs new file mode 100644 index 000000000..72a58f2d8 --- /dev/null +++ b/languages/golang/stackauth/guest/src/abi.rs @@ -0,0 +1,245 @@ +//! This guest's wasm export surface, under the `sa_` prefix, over the +//! conventions every guest shares (`stack_guest_abi::abi`: `se_alloc` / +//! `se_dealloc`, the buffer registry, the packed `u64` result encoding, the +//! hostile-input validation of every `(ptr, len)` pair). +//! +//! # The mount +//! +//! The host mounts the profile root at [`GUEST_ROOT`] and names a store's +//! directory on every call: the root itself, or a workspace directory +//! [`sa_workspace_dir`] returned. There is no init export and no handle: +//! the mount is the whole configuration, and a store is a directory. +//! +//! # Exports +//! +//! Every export takes UTF-8 strings as `(ptr, len)` pairs and returns a +//! packed result: a string, an FFI-codec value, or an empty buffer for an +//! operation with nothing to return. See [`crate::ops`] for each one's +//! meaning and encoding; the export is the validated, unwinding-safe +//! wrapper. [`sa_shutdown`] is the one lifetime call: it wipes every +//! buffer the registry still holds, so a host that tears the instance down +//! without releasing an output — a token, a key — leaves nothing behind. +//! +//! Wasm modules are single-threaded; the host must serialize calls into one +//! instance. + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use stack_guest_abi::abi::{err_status, input, ok_buffer}; +use stack_guest_abi::buffers; +use stack_guest_abi::status::STATUS_INTERNAL; + +use crate::{auth, ops}; + +/// Where the host mounts the profile root. The Go side mounts exactly one +/// directory here and constructs every store directory under it; nothing +/// in this module composes a path from anything else. +pub const GUEST_ROOT: &str = "/profile"; + +/// An operation over one validated input. +type Op1 = fn(&[u8]) -> Result<Vec<u8>, u32>; +/// An operation over two validated inputs. +type Op2 = fn(&[u8], &[u8]) -> Result<Vec<u8>, u32>; + +/// Run a two-input operation as an export: validate both pairs, run, +/// pack. A panic is `STATUS_INTERNAL` (wasm32-wasip1 aborts on panic; the +/// catch is belt-and-braces for an unwinding build). +fn export2(a_ptr: *const u8, a_len: u32, b_ptr: *const u8, b_len: u32, op: Op2) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + // SAFETY: host-owned ranges the export was handed; the borrows end + // when `op` returns, inside the call, and nothing here writes to + // linear memory while they are live. + let a = unsafe { input(a_ptr, a_len)? }; + let b = unsafe { input(b_ptr, b_len)? }; + op(a, b) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// [`export2`] for a one-input operation. +fn export1(a_ptr: *const u8, a_len: u32, op: Op1) -> u64 { + // SAFETY: as in `export2`. + catch_unwind(AssertUnwindSafe(|| op(unsafe { input(a_ptr, a_len)? }))) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// The current workspace id of the store at `dir`. See +/// [`ops::current_workspace`]. +/// +/// # Safety +/// +/// `dir_ptr`/`dir_len` should name a buffer the host wrote via `se_alloc`; +/// the range is bounds-checked against linear memory (a bad pair returns +/// `STATUS_ENCODING` instead of faulting). +#[no_mangle] +pub unsafe extern "C" fn sa_current_workspace(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::current_workspace) +} + +/// Set the current workspace of the store at `dir`. See +/// [`ops::set_current_workspace`]. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_set_current_workspace( + dir_ptr: *const u8, + dir_len: u32, + id_ptr: *const u8, + id_len: u32, +) -> u64 { + export2(dir_ptr, dir_len, id_ptr, id_len, ops::set_current_workspace) +} + +/// Clear the current workspace of the store at `dir`. See +/// [`ops::clear_current_workspace`]. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_clear_current_workspace(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::clear_current_workspace) +} + +/// The workspace ids under the store at `dir`. See +/// [`ops::list_workspaces`]. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_list_workspaces(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::list_workspaces) +} + +/// The directory of the workspace store `id` under the store at `dir`. +/// See [`ops::workspace_dir`]. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_workspace_dir( + dir_ptr: *const u8, + dir_len: u32, + id_ptr: *const u8, + id_len: u32, +) -> u64 { + export2(dir_ptr, dir_len, id_ptr, id_len, ops::workspace_dir) +} + +/// The lock file path for `filename` in the store at `dir`. See +/// [`ops::lock_path`]. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_lock_path( + dir_ptr: *const u8, + dir_len: u32, + name_ptr: *const u8, + name_len: u32, +) -> u64 { + export2(dir_ptr, dir_len, name_ptr, name_len, ops::lock_path) +} + +/// `secretkey.json` in the store at `dir`. See [`ops::secret_key`]. The +/// output carries the client key; the host copies it into its opaque +/// type and releases the buffer at once. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_secret_key(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::secret_key) +} + +/// `auth.json` in the store at `dir`. See [`ops::token`]. The output +/// carries the access token; the host releases the buffer once it has +/// read it. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_token(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::token) +} + +/// Whether this workspace has auth.json, without parsing its content. +/// # Safety +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_has_token(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::has_token) +} + +/// `device.json` in the store at `dir`, read-only. See +/// [`ops::device_identity`]. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_device_identity(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::device_identity) +} + +/// Construct an auth strategy from a tagged JSON config. Returns its handle. +/// The access key, when present, stays in the guest and is dropped on free. +/// # Safety +/// The input pair is validated against guest linear memory. +#[no_mangle] +pub unsafe extern "C" fn sa_auth_new(ptr: *const u8, len: u32) -> u64 { + export1(ptr, len, auth::create) +} + +/// Validate a workspace CRN using the same Rust parser as AutoStrategy. +/// # Safety +/// The input pair is validated against guest linear memory. +#[no_mangle] +pub unsafe extern "C" fn sa_auth_validate_crn(ptr: *const u8, len: u32) -> u64 { + export1(ptr, len, auth::validate_crn) +} + +/// Get a service token from a strategy. A device session returns +/// `STATUS_AUTH_REFRESH_REQUIRED` when it enters the refresh window; +/// this export never refreshes a device session or needs its file lock. +/// # Safety +/// The input pair is validated against guest linear memory. +#[no_mangle] +pub unsafe extern "C" fn sa_auth_token(ptr: *const u8, len: u32) -> u64 { + export1(ptr, len, auth::token) +} + +/// Refresh a device session. The Go host must hold the workspace's +/// auth.json lock across this whole call, including the disk re-read and save. +/// # Safety +/// The input pair is validated against guest linear memory. +#[no_mangle] +pub unsafe extern "C" fn sa_auth_refresh(ptr: *const u8, len: u32) -> u64 { + export1(ptr, len, auth::refresh) +} + +/// Drop one strategy and its cached credential. +/// # Safety +/// The input pair is validated against guest linear memory. +#[no_mangle] +pub unsafe extern "C" fn sa_auth_free(ptr: *const u8, len: u32) -> u64 { + export1(ptr, len, auth::free) +} + +/// Tear the instance down: wipe every buffer the registry still holds. +/// This guest keeps no other state. Idempotent; `se_alloc` and +/// `se_dealloc` keep working so the host can still free what it holds. +#[no_mangle] +pub extern "C" fn sa_shutdown() { + let _ = catch_unwind(AssertUnwindSafe(auth::clear)); + let _ = catch_unwind(AssertUnwindSafe(buffers::wipe_all)); +} diff --git a/languages/golang/stackauth/guest/src/auth.rs b/languages/golang/stackauth/guest/src/auth.rs new file mode 100644 index 000000000..e55212c3b --- /dev/null +++ b/languages/golang/stackauth/guest/src/auth.rs @@ -0,0 +1,209 @@ +//! Auth strategies retained inside one credential guest instance. The Go +//! package constructs typed sources; this registry owns the Rust strategies +//! and their cached CTS tokens until a source or the guest is closed. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; + +use cts_common::Crn; +use futures::executor::block_on; +use serde::Deserialize; +use stack_auth::{ + AccessKey, AccessKeyStrategy, AuthStrategy, DeviceSessionStrategy, OidcFederationStrategy, + Token, +}; +use stack_profile::ProfileStore; +use zeroize::Zeroizing; + +use crate::host::{HostOidcProvider, WasiAuthTransport}; +use crate::status::{ + status_for_auth, status_for_profile, STATUS_AUTH_CONFIG, STATUS_AUTH_REFRESH_REQUIRED, + STATUS_ENCODING, STATUS_STATE, +}; + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum Config { + AccessKey { + crn: String, + access_key: String, + base_url: Option<String>, + }, + Oidc { + crn: String, + provider: u32, + base_url: Option<String>, + }, + DeviceSession { + workspace_dir: String, + base_url: Option<String>, + }, +} + +enum Strategy { + AccessKey(AccessKeyStrategy), + Oidc(OidcFederationStrategy<HostOidcProvider>), + // A device session is rebuilt for refresh only after Go has acquired the + // cross-process lock. A fresh token can be read without the lock; keeping + // a cached Token here would defeat the post-lock re-read on refresh. + DeviceSession { + workspace_dir: String, + base_url: Option<url::Url>, + }, +} + +thread_local! { + static STRATEGIES: RefCell<HashMap<u32, Strategy>> = RefCell::new(HashMap::new()); + static NEXT: Cell<u32> = const { Cell::new(1) }; +} + +fn parse_base_url(value: Option<String>) -> Result<Option<url::Url>, u32> { + value + .map(|v| v.parse::<url::Url>().map_err(|_| STATUS_ENCODING)) + .transpose() +} + +pub fn validate_crn(bytes: &[u8]) -> Result<Vec<u8>, u32> { + let text = std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING)?; + let _: Crn = text.parse().map_err(|_| STATUS_AUTH_CONFIG)?; + Ok(Vec::new()) +} + +// A CRN or access key that does not parse is a configuration error, the +// class Rust's `AutoStrategy` reports (`INVALID_CRN`, `INVALID_ACCESS_KEY`) +// and the one `validate_crn` already uses. `STATUS_ENCODING` is kept for the +// JSON envelope itself. + +pub fn create(config: &[u8]) -> Result<Vec<u8>, u32> { + let config: Config = serde_json::from_slice(config).map_err(|_| STATUS_ENCODING)?; + let strategy = match config { + Config::AccessKey { + crn, + access_key, + base_url, + } => { + let access_key = Zeroizing::new(access_key); + let crn: Crn = crn.parse().map_err(|_| STATUS_AUTH_CONFIG)?; + let key: AccessKey = access_key.parse().map_err(|_| STATUS_AUTH_CONFIG)?; + let mut builder = AccessKeyStrategy::builder(crn, key).transport(WasiAuthTransport); + if let Some(url) = parse_base_url(base_url)? { + builder = builder.base_url(url); + } + Strategy::AccessKey(builder.build().map_err(|e| status_for_auth(&e))?) + } + Config::Oidc { + crn, + provider, + base_url, + } => { + let crn: Crn = crn.parse().map_err(|_| STATUS_AUTH_CONFIG)?; + let mut builder = OidcFederationStrategy::builder(crn, HostOidcProvider(provider)) + .transport(WasiAuthTransport); + if let Some(url) = parse_base_url(base_url)? { + builder = builder.base_url(url); + } + Strategy::Oidc(builder.build().map_err(|e| status_for_auth(&e))?) + } + Config::DeviceSession { + workspace_dir, + base_url, + } => { + if workspace_dir.is_empty() { + return Err(STATUS_ENCODING); + } + Strategy::DeviceSession { + workspace_dir, + base_url: parse_base_url(base_url)?, + } + } + }; + let id = NEXT.with(|next| { + let id = next.get(); + next.set(id.wrapping_add(1).max(1)); + id + }); + STRATEGIES.with(|items| { + let _ = items.borrow_mut().insert(id, strategy); + }); + Ok(id.to_string().into_bytes()) +} + +fn parse_handle(bytes: &[u8]) -> Result<u32, u32> { + let text = std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING)?; + text.parse::<u32>().map_err(|_| STATUS_ENCODING) +} + +pub fn token(handle: &[u8]) -> Result<Vec<u8>, u32> { + let id = parse_handle(handle)?; + STRATEGIES.with(|items| { + let items = items.borrow(); + let strategy = items.get(&id).ok_or(STATUS_STATE)?; + match strategy { + Strategy::AccessKey(strategy) => block_on(strategy.get_token()) + .map(|token| token.as_str().as_bytes().to_vec()) + .map_err(|e| status_for_auth(&e)), + Strategy::Oidc(strategy) => block_on(strategy.get_token()) + .map(|token| token.as_str().as_bytes().to_vec()) + .map_err(|e| status_for_auth(&e)), + Strategy::DeviceSession { + workspace_dir, + base_url, + } => cached_device_token(workspace_dir, base_url), + } + }) +} + +/// A fresh token needs only a profile read. The 90-second refresh window is +/// decided by stack-auth's Token, so Go does not duplicate that rule. +fn cached_device_token(workspace_dir: &str, base_url: &Option<url::Url>) -> Result<Vec<u8>, u32> { + let token: Token = ProfileStore::new(workspace_dir) + .load_profile() + .map_err(|e| status_for_profile(&e))?; + if token.region().is_none() || token.client_id().is_none() { + return Err(stack_guest_abi::status::STATUS_AUTH_NOT_AUTHENTICATED); + } + if base_url.is_none() { + let _ = token.issuer().map_err(|e| status_for_auth(&e))?; + } + if token.is_expired() { + return Err(STATUS_AUTH_REFRESH_REQUIRED); + } + Ok(token.access_token().as_str().as_bytes().to_vec()) +} + +/// Called only after Go acquires the profile lock. Building from the store +/// here re-reads a token another process may have rotated while Go waited. +pub fn refresh(handle: &[u8]) -> Result<Vec<u8>, u32> { + let id = parse_handle(handle)?; + STRATEGIES.with(|items| { + let items = items.borrow(); + let Strategy::DeviceSession { + workspace_dir, + base_url, + } = items.get(&id).ok_or(STATUS_STATE)? + else { + return Err(STATUS_STATE); + }; + let store = ProfileStore::new(workspace_dir); + let mut builder = + DeviceSessionStrategy::with_workspace_store(store).transport(WasiAuthTransport); + if let Some(url) = base_url { + builder = builder.base_url(url.clone()); + } + let strategy = builder.build().map_err(|e| status_for_auth(&e))?; + let token = block_on(strategy.get_token()).map_err(|e| status_for_auth(&e))?; + Ok(token.as_str().as_bytes().to_vec()) + }) +} + +pub fn free(handle: &[u8]) -> Result<Vec<u8>, u32> { + let id = parse_handle(handle)?; + STRATEGIES.with(|items| { + let _removed = items.borrow_mut().remove(&id).ok_or(STATUS_STATE)?; + Ok(Vec::new()) + }) +} + +pub fn clear() { + STRATEGIES.with(|items| items.borrow_mut().clear()); +} diff --git a/languages/golang/stackauth/guest/src/headers.rs b/languages/golang/stackauth/guest/src/headers.rs new file mode 100644 index 000000000..3ed454d0c --- /dev/null +++ b/languages/golang/stackauth/guest/src/headers.rs @@ -0,0 +1,147 @@ +//! The headers this guest's auth requests carry, over the wire format every +//! guest shares ([`stack_guest_abi::headers`]: `name: value` lines). +//! +//! stack-auth builds each request, `user-agent` included; this module is the +//! one change the guest makes on the way to the host: it names the host. + +use std::sync::OnceLock; + +use stack_guest_abi::headers::encode_headers; + +/// The host this guest is driven by, as it appears in [`user_agent`]. One +/// token for one build, as in the crypto guest's `headers` module. +const HOST: &str = "Go"; + +/// The `user-agent` every auth request from this guest carries: +/// `stack-auth/<version> (Go)`. +/// +/// Not optional, and not cosmetic: the edge in front of production CTS +/// refuses a host runtime's generic default (`Go-http-client/1.1`, which +/// Go's HTTP client fills in when a request carries none) with a bare nginx +/// 403 that never reaches CTS. +/// +/// stack-auth already sets one, `stack-auth/<version> (<os> <arch>)`, and +/// this replaces it rather than passing it through. Built for wasm32-wasip1 +/// that value would read `(wasi wasm32)`: true of the sandbox, useless to +/// anyone reading a log, and silent about the host that actually made the +/// request. The crypto guest's `stack-encrypt/<version> (Go)` names the +/// library and the host carrying it; this says the same of stack-auth, with +/// stack-auth's own version, so the product token means one thing from Rust +/// and from Go. +pub fn user_agent() -> &'static str { + static USER_AGENT: OnceLock<String> = OnceLock::new(); + USER_AGENT.get_or_init(|| format!("stack-auth/{} ({HOST})", stack_auth::VERSION)) +} + +/// `headers` (a stack-auth request's) in the host's wire format, with its +/// `user-agent` replaced by [`user_agent`]. Every other header crosses as +/// stack-auth built it, in order. +/// +/// This lives here rather than in `host` because `host` is +/// `#[cfg(target_arch = "wasm32")]` and so is never compiled, let alone +/// tested, on the native target. The header set is the kind of thing that +/// fails in production and nowhere else. +pub fn request_headers(headers: &[(String, String)]) -> Vec<u8> { + let pairs: Vec<(&str, &str)> = headers + .iter() + .filter(|(name, _)| !name.eq_ignore_ascii_case("user-agent")) + .map(|(name, value)| (name.as_str(), value.as_str())) + .chain(std::iter::once(("user-agent", user_agent()))) + .collect(); + encode_headers(&pairs) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use futures::executor::block_on; + use stack_auth::{ + AccessKeyStrategy, AuthStrategy, HttpRequest, HttpResponse, HttpTransport, RequestError, + }; + use stack_guest_abi::headers::header_value; + + use super::*; + + /// Captures the headers of every request stack-auth builds, as this + /// guest would hand them to the host, and answers with a 500. + #[derive(Default)] + struct Capture(Mutex<Vec<Vec<u8>>>); + + impl HttpTransport for Capture { + async fn send(&self, request: HttpRequest) -> Result<HttpResponse, RequestError> { + self.0 + .lock() + .unwrap() + .push(request_headers(request.headers())); + Ok(HttpResponse::new(500, Vec::new(), Vec::new())) + } + } + + fn user_agent_lines(headers: &[u8]) -> usize { + std::str::from_utf8(headers) + .unwrap() + .lines() + .filter(|line| line.to_ascii_lowercase().starts_with("user-agent:")) + .count() + } + + /// The edge in front of production CTS answers a request whose + /// `user-agent` is Go's default with a bare nginx 403, before CTS sees + /// it. Every request this guest hands the host must name the library + /// and the host, once, and keep the headers stack-auth built. + #[test] + fn every_request_identifies_itself() { + let capture = Arc::new(Capture::default()); + let strategy = AccessKeyStrategy::builder( + "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(), + "CSAKtestKeyId.testKeySecret".parse().unwrap(), + ) + .base_url("https://cts.example.com/".parse().unwrap()) + .transport(Arc::clone(&capture)) + .build() + .unwrap(); + let _ = block_on((&strategy).get_token()); + + let seen = capture.0.lock().unwrap(); + assert_eq!(seen.len(), 1, "one access-key exchange"); + let headers = &seen[0]; + let ua = header_value(headers, "user-agent").expect("requests carry a user-agent"); + assert_eq!( + ua, + format!("stack-auth/{} (Go)", stack_auth::VERSION), + "the user-agent names the library and the host carrying it" + ); + assert!( + !ua.contains("Go-http-client"), + "a host runtime's default user-agent is refused by the edge" + ); + assert_eq!( + user_agent_lines(headers), + 1, + "stack-auth's own user-agent is replaced, not sent alongside: {:?}", + String::from_utf8_lossy(headers) + ); + assert_eq!( + header_value(headers, "content-type"), + Some("application/json"), + "the content type travels with the user-agent" + ); + } + + #[test] + fn a_user_agent_is_replaced_whatever_its_case_and_the_rest_kept_in_order() { + let headers = request_headers(&[ + ("authorization".into(), "Bearer tok".into()), + ("User-Agent".into(), "stack-auth/0.0.0 (wasi wasm32)".into()), + ("content-type".into(), "application/json".into()), + ]); + assert_eq!( + String::from_utf8(headers).unwrap(), + format!( + "authorization: Bearer tok\ncontent-type: application/json\nuser-agent: {}", + user_agent() + ) + ); + } +} diff --git a/languages/golang/stackauth/guest/src/host.rs b/languages/golang/stackauth/guest/src/host.rs new file mode 100644 index 000000000..380b8306f --- /dev/null +++ b/languages/golang/stackauth/guest/src/host.rs @@ -0,0 +1,89 @@ +//! The credential guest's only network route: the host's HTTP transport. +//! A provider callback supplies the current OIDC token only when the Rust +//! federation strategy actually needs to exchange it. + +use std::io; + +use stack_auth::{ + AuthError, HttpRequest, HttpResponse, HttpTransport, OidcProvider, RequestError, SecretToken, +}; +use stack_guest_abi::{buffers, transport}; +use zeroize::Zeroizing; + +use crate::headers::request_headers; + +#[link(wasm_import_module = "cipherstash_transport")] +extern "C" { + fn oidc_token_get(provider: u32, ptr_out: *mut u32, len_out: *mut u32) -> i32; +} + +fn request_error(message: impl Into<String>) -> RequestError { + RequestError(Box::new(io::Error::other(message.into()))) +} + +pub struct WasiAuthTransport; + +impl HttpTransport for WasiAuthTransport { + async fn send(&self, request: HttpRequest) -> Result<HttpResponse, RequestError> { + // stack-auth's headers, naming this host in the `user-agent`: the + // edge in front of CTS refuses Go's default one (see `headers`). + let wire_headers = Zeroizing::new(request_headers(request.headers())); + let response = transport::send( + request.method(), + request.url().as_str(), + &wire_headers, + request.body(), + ) + .map_err(|e| request_error(e.to_string()))?; + if !(100..=599).contains(&response.status) { + return Err(request_error(if response.status < 0 { + String::from_utf8_lossy(&response.body).into_owned() + } else { + format!("invalid host HTTP status {}", response.status) + })); + } + let response_headers = response + .headers + .split(|b| *b == b'\n') + .filter_map(|line| { + let line = std::str::from_utf8(line).ok()?; + let (name, value) = line.split_once(':')?; + Some((name.trim().to_string(), value.trim().to_string())) + }) + .collect(); + Ok(HttpResponse::new( + response.status as u16, + response_headers, + response.body.to_vec(), + )) + } +} + +pub struct HostOidcProvider(pub u32); + +impl OidcProvider for HostOidcProvider { + async fn fetch(&self) -> Result<SecretToken, AuthError> { + let mut ptr = 0_u32; + let mut len = 0_u32; + // SAFETY: out-slots are live stack locals, filled synchronously. + let status = unsafe { oidc_token_get(self.0, &mut ptr, &mut len) }; + // Reclaim even on failure: a host may have allocated before it failed. + // SAFETY: the shared registry validates the host-provided pair. + let bytes = unsafe { buffers::take(ptr as *mut u8, len as usize) } + .map(Zeroizing::new) + .ok_or_else(|| AuthError::Request(request_error("invalid OIDC token buffer")))?; + if status != 0 { + return Err(AuthError::Request(request_error( + "OIDC provider could not supply a token", + ))); + } + let token = std::str::from_utf8(&bytes) + .map_err(|_| AuthError::Request(request_error("OIDC token is not UTF-8")))?; + if token.is_empty() || token.chars().any(char::is_control) { + return Err(AuthError::Request(request_error( + "OIDC token is empty or contains controls", + ))); + } + Ok(SecretToken::new(token)) + } +} diff --git a/languages/golang/stackauth/guest/src/lib.rs b/languages/golang/stackauth/guest/src/lib.rs new file mode 100644 index 000000000..421057ef0 --- /dev/null +++ b/languages/golang/stackauth/guest/src/lib.rs @@ -0,0 +1,111 @@ +// Security lints — the block `stack-encrypt` and `stack-auth` carry, minus +// `deny(unsafe_code)`: the export surface (`abi`) is `extern "C"` over raw +// pointers by nature. Every `unsafe` block is confined to that wasm32-only +// module and documented at the site; `unsafe_op_in_unsafe_fn` keeps each +// one explicit. The allocator, the buffer registry and the input +// validation are `stack-guest-abi`'s. +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +// Prevent mem::forget from bypassing ZeroizeOnDrop +#![warn(clippy::mem_forget)] +// Prevent accidental data leaks via output +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Relax in tests +#![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))] +// The crate's target is wasm32; `abi` only exists there, so on a native doc +// build its intra-doc links have nothing to resolve to. The wasm32 doc +// build (`mise run wasm:auth-guest:test`) is where links are enforced. +#![cfg_attr(not(target_arch = "wasm32"), allow(rustdoc::broken_intra_doc_links))] +//! # The credential guest +//! +//! WASI guest module exposing the developer profile — +//! [`stack_profile::ProfileStore`] over one mounted directory — to non-Rust +//! hosts. Built for `wasm32-wasip1` and embedded by the Go package +//! `stackauth` in the parent directory (wazero host, `CGO_ENABLED=0`). +//! ADR-0005 in `packages/stack-encrypt/docs/adr` is the decision this +//! module implements; `docs/plans/stack-encrypt-go-bindings.md` sequences +//! it. +//! +//! # What the host gives it, and what it does not +//! +//! wazero mounts exactly one directory into this module — the profile +//! root, at [`abi`]'s fixed guest path — and gives it **no environment**. +//! The Go side resolves `CS_CONFIG_PATH` and the home directory the way +//! [`ProfileStore::resolve`](stack_profile::ProfileStore::resolve) does, +//! mounts the result, and names the store's directory explicitly on every +//! call. Nothing here reads a variable, and nothing here can name a path +//! the mount does not contain: every path is built by `stack-profile` from +//! a store directory and a validated filename or workspace id. +//! +//! The crypto guest (`bindings/go/stackencrypt/guest`) is unchanged by +//! this one existing. It has no filesystem and no environment, and it +//! handles plaintext and data keys; this module can reach one directory of +//! credentials. The split is what makes both of those true at once. +//! +//! # What crosses the boundary +//! +//! Profile inputs are UTF-8 strings: the store directory (a guest path under +//! the mount), a workspace id, a filename. Outputs are either a UTF-8 string +//! (an id, a path) or a value in vitaminc's FFI codec +//! (`vitaminc_aead_value::transport`): a list of ids, or the fields of +//! `secretkey.json`, `auth.json` or `device.json` as an object. The client +//! key in `secretkey.json` crosses as the text the file holds; the Go side +//! wraps it in its opaque `ClientKey` and wipes its transport copy. A +//! refresh token never crosses: the auth strategy retains it inside this +//! module, while the Go host supplies HTTP through a single transport +//! import. The host can provide an access key or an OIDC provider callback. +//! +//! # Locking +//! +//! WASI preview 1 has no file locking, so this module takes none. The Go +//! side takes the same lock the Rust CLI takes, on the path +//! [`ProfileStore::lock_path`](stack_profile::ProfileStore::lock_path) +//! names (exported here), around the entire device-session refresh export. +//! A fresh token is read without that lock. The refresh export re-reads +//! auth.json after acquisition and saves a rotated token before returning. +//! Go never composes a profile path. +//! +//! # Not faked +//! +//! The process id and the hostname are compiled out of `stack-profile` on +//! wasm32, not stubbed: the creating half of `DeviceIdentity` is +//! native-only, and this module exposes only its read. A guest that could +//! mint an identity named after a made-up hostname would be worse than one +//! that cannot. +//! +//! Split into: +//! +//! - [`ops`], [`status`], [`headers`] — everything that is pure logic over a +//! `ProfileStore` and bytes. Compiles and unit-tests on the native host +//! target (`cargo test` here) against a temporary directory. +//! - [`auth`], [`host`] (wasm32 only) — stack-auth strategies and host HTTP. +//! - [`abi`] (wasm32 only) — the export surface, over the conventions +//! every guest shares (`stack_guest_abi`). + +pub mod headers; +pub mod ops; +pub mod status; + +#[cfg(target_arch = "wasm32")] +pub mod auth; +#[cfg(target_arch = "wasm32")] +pub mod host; + +// The ABI's packed u64 results embed 32-bit pointers and its bounds checks +// read the wasm linear-memory size, so this module only exists on wasm32. +// A native build therefore exports no sa_* symbols at all — failing loudly +// at symbol lookup — instead of a silently wrong ABI. +#[cfg(target_arch = "wasm32")] +pub mod abi; diff --git a/languages/golang/stackauth/guest/src/ops.rs b/languages/golang/stackauth/guest/src/ops.rs new file mode 100644 index 000000000..6e5ea6388 --- /dev/null +++ b/languages/golang/stackauth/guest/src/ops.rs @@ -0,0 +1,449 @@ +//! The guest's operations, written against [`stack_profile::ProfileStore`] +//! over a directory the caller names, so they compile — and their tests run +//! — on the native host target against a temporary directory. The +//! wasm32-only [`crate::abi`] module wires them to the packed ABI; nothing +//! in here knows about linear memory. +//! +//! Every operation takes the store's directory as bytes: on wasm32 that is +//! a guest path under the mount, and the Go side names it on every call +//! rather than the guest keeping a handle. A workspace-scoped store is the +//! same thing with a longer directory, which [`workspace_dir`] produces +//! through the crate's own validation. +//! +//! # Errors +//! +//! Every function reports a [`crate::status`] code, never a message. A +//! directory, id or filename that is not UTF-8, or an empty directory, is +//! [`STATUS_ENCODING`]; everything else is `stack-profile`'s verdict +//! ([`status_for_profile`]). +//! +//! # Copies +//! +//! `stack-profile` reads a file into a `String` and deserializes from it, +//! and this module builds the result out of what it deserialized. The +//! file's contents and the intermediate values are freed when they drop, +//! not wiped; the codec buffer that crosses to the host *is* wiped, by the +//! registry, once the host has taken it. What this leaves in freed guest +//! memory lives in linear memory the host locks, excludes from dumps and +//! wipes on release, and nowhere else. + +use serde::Deserialize; +use stack_auth::Token; +use stack_profile::{DeviceIdentity, ProfileStore}; +use vitaminc_aead_value::{transport as codec, FfiValue}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::status::{status_for_profile, STATUS_ENCODING, STATUS_INTERNAL}; + +/// The file `secretkey.json`, as `stack-auth`'s device client writes it +/// and `stack-kms`'s `SecretKey` reads it: the ZeroKMS client id and the +/// client key material, standard padded base64. The key crosses to the +/// host in the form the file holds, which is one of the two forms +/// `stackencrypt`'s config takes — as bytes, so the host gets a slice it +/// can wipe rather than a string it cannot. What is deserialized here is +/// wiped when it drops; what is moved out of it into the codec value is +/// wiped by the codec's own protected types. +#[derive(Deserialize, Zeroize, ZeroizeOnDrop)] +struct SecretKeyFile { + client_id: String, + client_key: String, +} + +/// The filename `stack-kms`'s `SecretKey` declares. Spelled here rather +/// than taken from that crate, which this guest does not build: its +/// `ProfileData` impl is where the name lives, and this constant is pinned +/// to it by the Go tests that write the file where `stash auth login` does. +const SECRET_KEY_FILENAME: &str = "secretkey.json"; + +/// The filename `stack-auth`'s `Token` declares. Kept here for the profile +/// read export, with a native test checking the crate's own name. +const AUTH_FILENAME: &str = "auth.json"; + +/// The store at `dir`. The directory is the caller's to name; an empty or +/// non-UTF-8 one is refused before any path is built. +pub fn store(dir: &[u8]) -> Result<ProfileStore, u32> { + let dir = text(dir)?; + if dir.is_empty() { + return Err(STATUS_ENCODING); + } + Ok(ProfileStore::new(dir)) +} + +fn text(bytes: &[u8]) -> Result<&str, u32> { + std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING) +} + +fn string(value: impl Into<String>) -> FfiValue { + FfiValue::String(value.into().into()) +} + +fn optional(value: Option<&str>) -> FfiValue { + value.map_or(FfiValue::Null, string) +} + +fn encode(value: FfiValue) -> Result<Vec<u8>, u32> { + let mut out = Vec::new(); + codec::encode_value(value, &mut out).map_err(|_| STATUS_INTERNAL)?; + Ok(out) +} + +/// The current workspace id, as text. +pub fn current_workspace(dir: &[u8]) -> Result<Vec<u8>, u32> { + store(dir)? + .current_workspace() + .map(String::into_bytes) + .map_err(|e| status_for_profile(&e)) +} + +/// Set the current workspace. The workspace must already have a directory; +/// a login creates one, this guest never does. Empty output. +pub fn set_current_workspace(dir: &[u8], id: &[u8]) -> Result<Vec<u8>, u32> { + store(dir)? + .set_current_workspace(text(id)?) + .map(|()| Vec::new()) + .map_err(|e| status_for_profile(&e)) +} + +/// Remove the current workspace selection. Empty output; nothing to remove +/// is not an error. +pub fn clear_current_workspace(dir: &[u8]) -> Result<Vec<u8>, u32> { + store(dir)? + .clear_current_workspace() + .map(|()| Vec::new()) + .map_err(|e| status_for_profile(&e)) +} + +/// The workspace ids with profile data on disk, sorted, as a codec array +/// of strings. +pub fn list_workspaces(dir: &[u8]) -> Result<Vec<u8>, u32> { + let ids = store(dir)? + .list_workspaces() + .map_err(|e| status_for_profile(&e))?; + encode(FfiValue::Array(ids.into_iter().map(string).collect())) +} + +/// The directory of the store scoped to workspace `id`, as text: what +/// [`ProfileStore::workspace_store`] would root a store at, after the +/// crate has validated the id. The host names this directory on the calls +/// it scopes to that workspace. +pub fn workspace_dir(dir: &[u8], id: &[u8]) -> Result<Vec<u8>, u32> { + let scoped = store(dir)? + .workspace_store(text(id)?) + .map_err(|e| status_for_profile(&e))?; + path_bytes(scoped.dir()) +} + +/// The path of the lock file [`ProfileStore::lock_exclusive`] would take +/// for `filename`, as text. Nothing is created or locked: the host holds +/// the lock, on this path, because this guest cannot. +pub fn lock_path(dir: &[u8], filename: &[u8]) -> Result<Vec<u8>, u32> { + let path = store(dir)? + .lock_path(text(filename)?) + .map_err(|e| status_for_profile(&e))?; + path_bytes(&path) +} + +fn path_bytes(path: &std::path::Path) -> Result<Vec<u8>, u32> { + // A path the crate built from UTF-8 parts is UTF-8; anything else is + // this module's bug, not the caller's. + path.to_str() + .map(|s| s.as_bytes().to_vec()) + .ok_or(STATUS_INTERNAL) +} + +/// `secretkey.json` in this store, as a codec object `{client_id, +/// client_key}`: the client id as a string, and the key material as +/// bytes, in the form the file holds it. +pub fn secret_key(dir: &[u8]) -> Result<Vec<u8>, u32> { + let mut file: SecretKeyFile = store(dir)? + .load(SECRET_KEY_FILENAME) + .map_err(|e| status_for_profile(&e))?; + // Moved out rather than copied: `SecretKeyFile` wipes on drop, so its + // fields cannot be moved out of it directly. + let client_id = std::mem::take(&mut file.client_id); + let client_key = std::mem::take(&mut file.client_key); + encode(FfiValue::Object(vec![ + ("client_id".to_string(), string(client_id)), + ( + "client_key".to_string(), + FfiValue::Bytes(client_key.into_bytes().into()), + ), + ])) +} + +/// `auth.json` in this store, read as [`stack_auth::Token`] so its shape is +/// the crate's, as a codec object: `access_token` and `token_type` +/// (strings), `expires_at` (seconds since the epoch, `u64`), and `region`, +/// `client_id` and `device_instance_id` (each a string or null). The +/// refresh token is not in it, on purpose: the host presents the access +/// token and refuses it at expiry; the auth strategy exports handle refresh. +pub fn token(dir: &[u8]) -> Result<Vec<u8>, u32> { + let token: Token = store(dir)? + .load(AUTH_FILENAME) + .map_err(|e| status_for_profile(&e))?; + encode(FfiValue::Object(vec![ + ( + "access_token".to_string(), + string(token.access_token().as_str()), + ), + ("token_type".to_string(), string(token.token_type())), + ( + "expires_at".to_string(), + FfiValue::UInt64(token.expires_at()), + ), + ("region".to_string(), optional(token.region())), + ("client_id".to_string(), optional(token.client_id())), + ( + "device_instance_id".to_string(), + optional(token.device_instance_id()), + ), + ])) +} + +/// Whether auth.json exists, without parsing it. AutoStrategy makes this +/// selection before building a device strategy, so malformed JSON must still +/// select the device path and then report its profile error. +pub fn has_token(dir: &[u8]) -> Result<Vec<u8>, u32> { + Ok(vec![u8::from(store(dir)?.exists_profile::<Token>())]) +} + +/// `device.json` in this store, read-only, as a codec object +/// `{device_instance_id, device_name}`. Creating one is the CLI's. +pub fn device_identity(dir: &[u8]) -> Result<Vec<u8>, u32> { + let identity = DeviceIdentity::load(&store(dir)?).map_err(|e| status_for_profile(&e))?; + encode(FfiValue::Object(vec![ + ( + "device_instance_id".to_string(), + string(identity.device_instance_id.to_string()), + ), + ("device_name".to_string(), string(identity.device_name)), + ])) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::status::*; + + const WS_A: &str = "AAAAAAAAAAAAAAAA"; + const WS_B: &str = "BBBBBBBBBBBBBBBB"; + + fn dir(t: &tempfile::TempDir) -> Vec<u8> { + t.path().to_str().unwrap().as_bytes().to_vec() + } + + fn decode(bytes: &[u8]) -> FfiValue { + codec::decode_value(&mut codec::Reader::new(bytes)).unwrap() + } + + fn field<'a>(value: &'a FfiValue, key: &str) -> &'a FfiValue { + let FfiValue::Object(entries) = value else { + panic!("not an object"); + }; + entries + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v) + .unwrap_or_else(|| panic!("no field {key}")) + } + + fn as_text(value: &FfiValue) -> String { + let FfiValue::String(s) = value else { + panic!("not a string"); + }; + String::from_utf8(s.risky_ref().to_vec()).unwrap() + } + + fn as_bytes(value: &FfiValue) -> Vec<u8> { + use vitaminc_protected::Controlled; + let FfiValue::Bytes(b) = value else { + panic!("not bytes"); + }; + b.risky_ref().to_vec() + } + + #[test] + fn has_token_selects_existing_profile_without_parsing_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(AUTH_FILENAME); + let store_dir = dir.path().to_str().unwrap().as_bytes(); + assert_eq!(has_token(store_dir).unwrap(), vec![0]); + std::fs::write(path, "{").unwrap(); + assert_eq!(has_token(store_dir).unwrap(), vec![1]); + } + + /// The names this guest spells are the crates' own. + #[test] + fn spelled_filenames_are_the_crates() { + use stack_profile::ProfileData; + assert_eq!(AUTH_FILENAME, <Token as ProfileData>::FILENAME); + assert_eq!( + <DeviceIdentity as ProfileData>::FILENAME, + "device.json", + "the device identity is read through its own impl" + ); + } + + #[test] + fn a_directory_must_be_utf8_and_non_empty() { + assert_eq!(store(b"").unwrap_err(), STATUS_ENCODING); + assert_eq!(store(&[0xff, 0xfe]).unwrap_err(), STATUS_ENCODING); + assert!(store(b"/profile").is_ok()); + } + + #[test] + fn workspace_selection_round_trips_and_lists() { + let t = tempfile::tempdir().unwrap(); + let d = dir(&t); + assert_eq!( + current_workspace(&d).unwrap_err(), + STATUS_PROFILE_NO_CURRENT_WORKSPACE + ); + // A workspace must exist before it can be current: a login makes + // the directory, this guest never does. + assert_eq!( + set_current_workspace(&d, WS_A.as_bytes()).unwrap_err(), + STATUS_PROFILE_WORKSPACE_NOT_FOUND + ); + std::fs::create_dir_all(t.path().join("workspaces").join(WS_B)).unwrap(); + std::fs::create_dir_all(t.path().join("workspaces").join(WS_A)).unwrap(); + assert_eq!(set_current_workspace(&d, WS_A.as_bytes()).unwrap(), b""); + assert_eq!(current_workspace(&d).unwrap(), WS_A.as_bytes()); + + let listed = decode(&list_workspaces(&d).unwrap()); + let FfiValue::Array(items) = listed else { + panic!("not an array"); + }; + let ids: Vec<String> = items.iter().map(as_text).collect(); + assert_eq!(ids, vec![WS_A, WS_B], "sorted, as the crate lists them"); + + assert_eq!(clear_current_workspace(&d).unwrap(), b""); + assert_eq!( + current_workspace(&d).unwrap_err(), + STATUS_PROFILE_NO_CURRENT_WORKSPACE + ); + assert_eq!( + clear_current_workspace(&d).unwrap(), + b"", + "clearing twice is not an error" + ); + } + + #[test] + fn workspace_dir_is_the_crates_and_the_id_is_validated_first() { + let t = tempfile::tempdir().unwrap(); + let d = dir(&t); + let scoped = workspace_dir(&d, WS_A.as_bytes()).unwrap(); + assert_eq!( + std::path::Path::new(std::str::from_utf8(&scoped).unwrap()), + t.path().join("workspaces").join(WS_A) + ); + for bad in ["../escape", "", "aaaaaaaaaaaaaaaa", "0000000000000000"] { + assert_eq!( + workspace_dir(&d, bad.as_bytes()).unwrap_err(), + STATUS_PROFILE_INVALID_WORKSPACE_ID, + "{bad:?}" + ); + } + assert_eq!( + workspace_dir(&d, &[0xff]).unwrap_err(), + STATUS_ENCODING, + "an id that is not UTF-8 is refused before validation" + ); + } + + #[test] + fn lock_path_names_the_sibling_lock_file_and_validates_the_filename() { + let t = tempfile::tempdir().unwrap(); + let d = dir(&t); + let path = lock_path(&d, b"auth.json").unwrap(); + assert_eq!( + std::path::Path::new(std::str::from_utf8(&path).unwrap()), + t.path().join(".auth.json.lock") + ); + assert!( + !t.path().join(".auth.json.lock").exists(), + "nothing is created" + ); + for bad in ["", "../auth.json", "/etc/auth.json", "a/b.json"] { + assert_eq!( + lock_path(&d, bad.as_bytes()).unwrap_err(), + STATUS_PROFILE_INVALID_FILENAME, + "{bad:?}" + ); + } + } + + #[test] + fn typed_reads_return_the_files_fields_and_not_found_otherwise() { + let t = tempfile::tempdir().unwrap(); + let d = dir(&t); + assert_eq!(secret_key(&d).unwrap_err(), STATUS_PROFILE_NOT_FOUND); + assert_eq!(token(&d).unwrap_err(), STATUS_PROFILE_NOT_FOUND); + assert_eq!(device_identity(&d).unwrap_err(), STATUS_PROFILE_NOT_FOUND); + + std::fs::write( + t.path().join("secretkey.json"), + r#"{"client_id":"6a70bd18-99ac-4650-b104-37eec3a15b09","client_key":"AAECAw=="}"#, + ) + .unwrap(); + let key = decode(&secret_key(&d).unwrap()); + assert_eq!( + as_text(field(&key, "client_id")), + "6a70bd18-99ac-4650-b104-37eec3a15b09" + ); + assert_eq!( + as_bytes(field(&key, "client_key")), + b"AAECAw==", + "the key crosses as bytes, in the form the file holds" + ); + + std::fs::write( + t.path().join("auth.json"), + r#"{"access_token":"tok","refresh_token":"refresh","token_type":"Bearer","expires_at":1800000000,"region":"ap-southeast-2"}"#, + ) + .unwrap(); + let tok = decode(&token(&d).unwrap()); + assert_eq!(as_text(field(&tok, "access_token")), "tok"); + assert_eq!(as_text(field(&tok, "token_type")), "Bearer"); + assert!(matches!( + field(&tok, "expires_at"), + FfiValue::UInt64(1_800_000_000) + )); + assert_eq!(as_text(field(&tok, "region")), "ap-southeast-2"); + assert!(matches!(field(&tok, "client_id"), FfiValue::Null)); + let FfiValue::Object(entries) = tok else { + panic!("not an object"); + }; + assert!( + entries.iter().all(|(k, _)| k != "refresh_token"), + "the refresh token never crosses to the host" + ); + + std::fs::write( + t.path().join("device.json"), + r#"{"device_instance_id":"0f4a4fd7-4a1a-4c5e-9d3e-7a4c8e3a9c11","device_name":"laptop"}"#, + ) + .unwrap(); + let identity = decode(&device_identity(&d).unwrap()); + assert_eq!( + as_text(field(&identity, "device_instance_id")), + "0f4a4fd7-4a1a-4c5e-9d3e-7a4c8e3a9c11" + ); + assert_eq!(as_text(field(&identity, "device_name")), "laptop"); + } + + #[test] + fn a_malformed_file_is_a_json_status_not_a_trap() { + let t = tempfile::tempdir().unwrap(); + let d = dir(&t); + std::fs::write(t.path().join("auth.json"), "{not json").unwrap(); + assert_eq!(token(&d).unwrap_err(), STATUS_PROFILE_JSON); + std::fs::write(t.path().join("secretkey.json"), r#"{"client_id":"x"}"#).unwrap(); + assert_eq!( + secret_key(&d).unwrap_err(), + STATUS_PROFILE_JSON, + "a missing field is a shape error" + ); + } +} diff --git a/languages/golang/stackauth/guest/src/status.rs b/languages/golang/stackauth/guest/src/status.rs new file mode 100644 index 000000000..0ac77b52b --- /dev/null +++ b/languages/golang/stackauth/guest/src/status.rs @@ -0,0 +1,124 @@ +//! Map profile and auth errors onto the shared guest status table. +//! +//! The numbers are [`stack_guest_abi::status`]'s — one table for every +//! guest, decoded once by the Go host — re-exported here so this crate's +//! modules and tests name them as the crypto guest names its own. What +//! this guest decides is *which* number a profile failure is: the seven +//! `stack-profile` conditions a Go caller can act on each have one, and +//! the two it cannot act on are internal. + +use stack_auth::AuthError; +use stack_profile::ProfileError; + +pub use stack_guest_abi::status::{ + STATUS_AUTH_CONFIG, STATUS_AUTH_REFRESH_REQUIRED, STATUS_ENCODING, STATUS_INTERNAL, + STATUS_PROFILE_INVALID_FILENAME, STATUS_PROFILE_INVALID_WORKSPACE_ID, STATUS_PROFILE_IO, + STATUS_PROFILE_JSON, STATUS_PROFILE_NOT_FOUND, STATUS_PROFILE_NO_CURRENT_WORKSPACE, + STATUS_PROFILE_WORKSPACE_NOT_FOUND, STATUS_STATE, +}; + +/// Preserve the auth decisions callers can act on without exposing token +/// bytes or parsing a server message across the ABI. +pub fn status_for_auth(error: &AuthError) -> u32 { + use stack_guest_abi::status::*; + if let AuthError::Store(store_error) = error { + return status_for_profile(&store_error.0); + } + match error.error_code() { + "INVALID_GRANT" => STATUS_AUTH_INVALID_GRANT, + "INVALID_CLIENT" => STATUS_AUTH_INVALID_CLIENT, + "USAGE_LIMIT_EXCEEDED" => STATUS_AUTH_USAGE_LIMIT, + "NOT_AUTHENTICATED" | "EXPIRED_TOKEN" => STATUS_AUTH_NOT_AUTHENTICATED, + "REQUEST_ERROR" | "SERVER_ERROR" => STATUS_AUTH_TRANSPORT, + "INVALID_URL" + | "INVALID_REGION" + | "INVALID_CRN" + | "WORKSPACE_MISMATCH" + | "INVALID_WORKSPACE_ID" + | "MISSING_WORKSPACE_CRN" + | "INVALID_ACCESS_KEY" + | "INVALID_TOKEN" => STATUS_AUTH_CONFIG, + _ => STATUS_AUTH_OTHER, + } +} + +/// A profile error as a status code. +/// +/// `HomeDirNotFound` is [`STATUS_INTERNAL`]: this guest is given its +/// directory and never resolves one, so reaching that variant would be a +/// bug here, not a condition for the host. `ProfileError` is +/// `#[non_exhaustive]`, so the catch-all is required, and it goes the same +/// way: a variant added upstream is reported as ours until someone reads +/// it and gives it a number. +pub fn status_for_profile(error: &ProfileError) -> u32 { + match error { + ProfileError::Io(_) => STATUS_PROFILE_IO, + ProfileError::Json(_) => STATUS_PROFILE_JSON, + ProfileError::NotFound { .. } => STATUS_PROFILE_NOT_FOUND, + ProfileError::InvalidFilename(_) => STATUS_PROFILE_INVALID_FILENAME, + ProfileError::NoCurrentWorkspace => STATUS_PROFILE_NO_CURRENT_WORKSPACE, + ProfileError::InvalidWorkspaceId(_) => STATUS_PROFILE_INVALID_WORKSPACE_ID, + ProfileError::WorkspaceNotFound(_) => STATUS_PROFILE_WORKSPACE_NOT_FOUND, + ProfileError::HomeDirNotFound => STATUS_INTERNAL, + _ => STATUS_INTERNAL, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_actionable_profile_error_has_its_own_code() { + let cases: Vec<(ProfileError, u32)> = vec![ + ( + ProfileError::Io(std::io::Error::other("disk")), + STATUS_PROFILE_IO, + ), + ( + serde_json::from_str::<u8>("nope") + .map_err(ProfileError::Json) + .unwrap_err(), + STATUS_PROFILE_JSON, + ), + ( + ProfileError::NotFound { path: "x".into() }, + STATUS_PROFILE_NOT_FOUND, + ), + ( + ProfileError::InvalidFilename("../x".into()), + STATUS_PROFILE_INVALID_FILENAME, + ), + ( + ProfileError::NoCurrentWorkspace, + STATUS_PROFILE_NO_CURRENT_WORKSPACE, + ), + ( + ProfileError::InvalidWorkspaceId("short".into()), + STATUS_PROFILE_INVALID_WORKSPACE_ID, + ), + ( + ProfileError::WorkspaceNotFound("AAAAAAAAAAAAAAAA".into()), + STATUS_PROFILE_WORKSPACE_NOT_FOUND, + ), + (ProfileError::HomeDirNotFound, STATUS_INTERNAL), + ]; + let mut seen = std::collections::HashSet::new(); + for (error, expected) in cases { + assert_eq!(status_for_profile(&error), expected, "{error}"); + if expected != STATUS_INTERNAL { + assert!(seen.insert(expected), "code {expected} is shared"); + } + } + } + + #[test] + fn auth_store_errors_keep_their_profile_status() { + let missing = AuthError::from(ProfileError::NotFound { + path: "auth.json".into(), + }); + assert_eq!(status_for_auth(&missing), STATUS_PROFILE_NOT_FOUND); + let io = AuthError::from(ProfileError::Io(std::io::Error::other("disk full"))); + assert_eq!(status_for_auth(&io), STATUS_PROFILE_IO); + } +} diff --git a/languages/golang/stackauth/lock_unix.go b/languages/golang/stackauth/lock_unix.go new file mode 100644 index 000000000..a8bc65c54 --- /dev/null +++ b/languages/golang/stackauth/lock_unix.go @@ -0,0 +1,41 @@ +//go:build !windows + +package stackauth + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "golang.org/x/sys/unix" +) + +// The lock file and flock match stack-profile's native FileLockGuard. +func withRefreshLock(ctx context.Context, path string, run func() error) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // path is inside the caller's profile directory + if err != nil { + return fmt.Errorf("stackauth: open refresh lock: %w", err) + } + defer f.Close() + for { + if err := ctx.Err(); err != nil { + return err + } + err = unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if err == nil { + break + } + if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EAGAIN) { + return fmt.Errorf("stackauth: acquire refresh lock: %w", err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(20 * time.Millisecond): + } + } + defer func() { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) }() + return run() +} diff --git a/languages/golang/stackauth/lock_windows.go b/languages/golang/stackauth/lock_windows.go new file mode 100644 index 000000000..c327a8336 --- /dev/null +++ b/languages/golang/stackauth/lock_windows.go @@ -0,0 +1,44 @@ +//go:build windows + +package stackauth + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "golang.org/x/sys/windows" +) + +// Lock the same first 2^64-1 bytes stack-profile's Windows FileLockGuard +// locks. OVERLAPPED offset zero makes the range identical. +func withRefreshLock(ctx context.Context, path string, run func() error) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // path is inside the caller's profile directory + if err != nil { + return fmt.Errorf("stackauth: open refresh lock: %w", err) + } + defer f.Close() + h := windows.Handle(f.Fd()) + var overlap windows.Overlapped + for { + if err := ctx.Err(); err != nil { + return err + } + err = windows.LockFileEx(h, windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 0xffffffff, 0xffffffff, &overlap) + if err == nil { + break + } + if !errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return fmt.Errorf("stackauth: acquire refresh lock: %w", err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(20 * time.Millisecond): + } + } + defer func() { _ = windows.UnlockFileEx(h, 0, 0xffffffff, 0xffffffff, &overlap) }() + return run() +} diff --git a/languages/golang/stackauth/mount.go b/languages/golang/stackauth/mount.go new file mode 100644 index 000000000..f4ef72e3d --- /dev/null +++ b/languages/golang/stackauth/mount.go @@ -0,0 +1,163 @@ +package stackauth + +import ( + "errors" + "io/fs" + "os" + "path" + + experimentalsys "github.com/tetratelabs/wazero/experimental/sys" + "github.com/tetratelabs/wazero/experimental/sysfs" + "github.com/tetratelabs/wazero/sys" +) + +// confinedFS is the one directory the guest is given, held to that +// directory. wazero's own directory mount joins the guest's path onto the +// host directory and lets the operating system resolve it, symlinks +// included, with the host process's permissions: a profile entry that is +// a symlink to a file elsewhere would read, and a current_workspace that +// is a symlink would write, outside the boundary ADR-0005 draws. Here +// every path the guest names is first resolved by os.Root — which follows +// symlinks only while they stay inside the directory and refuses the ones +// that leave it — and only a path that stays inside reaches the mount. +// +// The check runs before the operation rather than as part of it: os.Root +// answers where the path leads, and wazero's mount then performs the +// operation on the host path. A change to the directory between the two +// is not defended against, deliberately. The profile directory is the +// user's own, and anything able to race a symlink into it can read the +// credentials directly; the boundary here is against what the directory +// contains, not against a concurrent attacker inside it. +type confinedFS struct { + // FS is wazero's directory mount, which performs every operation. + experimentalsys.FS + root *os.Root +} + +func newConfinedFS(dir string) (*confinedFS, error) { + root, err := os.OpenRoot(dir) + if err != nil { + return nil, err + } + return &confinedFS{FS: sysfs.DirFS(dir), root: root}, nil +} + +// Close releases the directory handle the root holds. +func (c *confinedFS) Close() error { return c.root.Close() } + +// confine is the errno an operation on p should fail with instead of +// running, or zero when p resolves inside the directory. p is as wazero +// hands it: relative to the mount, "." for the mount itself. A path that +// does not exist yet is confined when its parent is, which is what a +// create needs; a symlink that leaves the directory, at any component, is +// a refusal, as is a component that is not a directory. +func (c *confinedFS) confine(p string) experimentalsys.Errno { + _, err := c.root.Stat(p) + switch { + case err == nil: + return 0 + case !errors.Is(err, fs.ErrNotExist): + return experimentalsys.EACCES + } + parent := path.Dir(p) + if parent == p { + return 0 + } + if _, err := c.root.Stat(parent); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return experimentalsys.ENOENT + } + return experimentalsys.EACCES + } + return 0 +} + +func (c *confinedFS) OpenFile(p string, flag experimentalsys.Oflag, perm fs.FileMode) (experimentalsys.File, experimentalsys.Errno) { + if errno := c.confine(p); errno != 0 { + return nil, errno + } + return c.FS.OpenFile(p, flag, perm) +} + +func (c *confinedFS) Lstat(p string) (sys.Stat_t, experimentalsys.Errno) { + if errno := c.confine(p); errno != 0 { + return sys.Stat_t{}, errno + } + return c.FS.Lstat(p) +} + +func (c *confinedFS) Stat(p string) (sys.Stat_t, experimentalsys.Errno) { + if errno := c.confine(p); errno != 0 { + return sys.Stat_t{}, errno + } + return c.FS.Stat(p) +} + +func (c *confinedFS) Mkdir(p string, perm fs.FileMode) experimentalsys.Errno { + if errno := c.confine(p); errno != 0 { + return errno + } + return c.FS.Mkdir(p, perm) +} + +func (c *confinedFS) Chmod(p string, perm fs.FileMode) experimentalsys.Errno { + if errno := c.confine(p); errno != 0 { + return errno + } + return c.FS.Chmod(p, perm) +} + +func (c *confinedFS) Rename(from, to string) experimentalsys.Errno { + if errno := c.confine(from); errno != 0 { + return errno + } + if errno := c.confine(to); errno != 0 { + return errno + } + return c.FS.Rename(from, to) +} + +func (c *confinedFS) Rmdir(p string) experimentalsys.Errno { + if errno := c.confine(p); errno != 0 { + return errno + } + return c.FS.Rmdir(p) +} + +func (c *confinedFS) Unlink(p string) experimentalsys.Errno { + if errno := c.confine(p); errno != 0 { + return errno + } + return c.FS.Unlink(p) +} + +func (c *confinedFS) Link(oldPath, newPath string) experimentalsys.Errno { + if errno := c.confine(oldPath); errno != 0 { + return errno + } + if errno := c.confine(newPath); errno != 0 { + return errno + } + return c.FS.Link(oldPath, newPath) +} + +func (c *confinedFS) Symlink(oldPath, linkName string) experimentalsys.Errno { + if errno := c.confine(linkName); errno != 0 { + return errno + } + return c.FS.Symlink(oldPath, linkName) +} + +func (c *confinedFS) Readlink(p string) (string, experimentalsys.Errno) { + if errno := c.confine(p); errno != 0 { + return "", errno + } + return c.FS.Readlink(p) +} + +func (c *confinedFS) Utimens(p string, atim, mtim int64) experimentalsys.Errno { + if errno := c.confine(p); errno != 0 { + return errno + } + return c.FS.Utimens(p, atim, mtim) +} diff --git a/languages/golang/stackauth/oauth2.go b/languages/golang/stackauth/oauth2.go new file mode 100644 index 000000000..37056a790 --- /dev/null +++ b/languages/golang/stackauth/oauth2.go @@ -0,0 +1,26 @@ +package stackauth + +import ( + "context" + "errors" + + "golang.org/x/oauth2" +) + +// OAuth2TokenSource adapts an existing x/oauth2 provider to OIDCProvider. +// Its AccessToken is read afresh whenever the Rust federation strategy asks. +func OAuth2TokenSource(source oauth2.TokenSource) OIDCProvider { + return OIDCProviderFunc(func(context.Context) (string, error) { + if source == nil { + return "", errors.New("stackauth: nil oauth2 token source") + } + token, err := source.Token() + if err != nil { + return "", err + } + if token == nil || token.AccessToken == "" { + return "", errors.New("stackauth: oauth2 source returned no access token") + } + return token.AccessToken, nil + }) +} diff --git a/languages/golang/stackauth/store.go b/languages/golang/stackauth/store.go new file mode 100644 index 000000000..cc04be9d4 --- /dev/null +++ b/languages/golang/stackauth/store.go @@ -0,0 +1,490 @@ +package stackauth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" + "github.com/tetratelabs/wazero/api" +) + +// Option configures Resolve and Open. +type Option func(*options) + +type options struct { + guest []byte + requireLocked bool + transport http.RoundTripper +} + +// WithRoundTripper sends the guest's authentication requests through rt. +// The default is http.DefaultTransport. +func WithRoundTripper(rt http.RoundTripper) Option { + return func(o *options) { o.transport = rt } +} + +// WithGuest overrides the embedded wasm module. +func WithGuest(wasm []byte) Option { + return func(o *options) { o.guest = wasm } +} + +// RequireLockedMemory makes Open fail with ErrMemoryLock when the guest's +// memory cannot be locked in RAM or, on Linux, excluded from core dumps, +// instead of continuing with memory that may be swapped or dumped and +// reporting so through [ProfileStore.MemoryLocked]. It holds for the life +// of the store, as stackencrypt's WithRequireLockedMemory does for a +// client. +func RequireLockedMemory() Option { + return func(o *options) { o.requireLocked = true } +} + +// ProfileStore is a profile directory: the root [Resolve] or [Open] returns, +// or a workspace directory under it from [ProfileStore.WorkspaceStore]. All +// stores from one root share one guest instance over the one mounted +// directory; Close on any of them releases it. It is safe for concurrent +// use; calls are serialised, because a wasm instance is single-threaded. +type ProfileStore struct { + root *root + // dir is the store's directory as the guest sees it: guestRoot, or a + // workspace directory the guest itself named under it. + dir string +} + +// root is the guest instance the stores of one profile share. +type root struct { + mu sync.Mutex + inst *instance + hostDir string + closed bool + // released is the runtime's own state, apart from closed: an + // interrupted call closes the module, and so the stores, while the + // runtime is still allocated. Close frees it even then. + released bool + cleanup runtime.Cleanup +} + +// Resolve opens the profile directory the Rust crate would: CS_CONFIG_PATH +// when set and not blank, else ~/.cipherstash. The directory must exist; +// `stash auth login` creates it. +func Resolve(ctx context.Context, opts ...Option) (*ProfileStore, error) { + // Blankness is tested on the trimmed value and the value itself is + // used, as ProfileStore::resolve does: a directory named with a space + // in it is the directory it names. + dir := os.Getenv("CS_CONFIG_PATH") + if strings.TrimSpace(dir) == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("%w: no home directory and CS_CONFIG_PATH is unset: %w", ErrNoProfile, err) + } + dir = filepath.Join(home, ".cipherstash") + } + return Open(ctx, dir, opts...) +} + +// Open instantiates the guest over dir, an existing profile directory, +// mounted as the one directory the guest can see. Nothing is read until a +// method asks; nothing is written unless a method writes. +func Open(ctx context.Context, dir string, opts ...Option) (*ProfileStore, error) { + info, err := os.Stat(dir) //nolint:gosec // the caller chooses the profile directory + if err != nil { + return nil, fmt.Errorf("%w: %s: %w", ErrNoProfile, dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("%w: %s is not a directory", ErrNoProfile, dir) + } + return open(ctx, dir, opts) +} + +// OpenWithoutProfile instantiates the guest with no directory mounted: no +// filesystem at all. It is for the strategies that need no profile — +// [ProfileStore.AccessKey] and [ProfileStore.OIDC] — where there is none to +// open: CI, a container, a server authenticating by federation. Every +// profile read on it fails with ErrNoProfile, and [ProfileStore.Auto] on it +// is the environment's access key or ErrNotAuthenticated, as stack-auth's +// AutoStrategy is with no profile store. +func OpenWithoutProfile(ctx context.Context, opts ...Option) (*ProfileStore, error) { + return open(ctx, "", opts) +} + +// open instantiates the guest over hostDir — mounted as the one directory +// the guest can see, or, when empty, nothing — and arms its cleanup. Open +// and OpenWithoutProfile differ only in what they hand it. +func open(ctx context.Context, hostDir string, opts []Option) (*ProfileStore, error) { + var o options + for _, opt := range opts { + opt(&o) + } + wasm := o.guest + if wasm == nil { + var err error + if wasm, err = embeddedGuest(); err != nil { + return nil, err + } + } + inst, err := newInstance(ctx, wasm, hostDir, guest.PolicyFor(o.requireLocked), o.transport) + if err != nil { + return nil, err + } + r := &root{inst: inst, hostDir: hostDir} + // The cleanup takes the instance, not the root: a cleanup whose + // argument reaches its object keeps that object alive forever. + r.cleanup = runtime.AddCleanup(r, func(inst *instance) { _ = inst.release() }, inst) + return &ProfileStore{root: r, dir: guestRoot}, nil +} + +// Dir is the store's directory on the host: the profile root, or the +// workspace directory under it. Empty for a store from +// [OpenWithoutProfile]. +func (s *ProfileStore) Dir() string { return s.hostPath(s.dir) } + +// hostPath maps a guest path under the mount to the host path it names. +func (s *ProfileStore) hostPath(guestPath string) string { + if s.root.hostDir == "" { + return "" + } + rel := strings.TrimPrefix(guestPath, guestRoot) + parts := strings.Split(strings.TrimPrefix(rel, "/"), "/") + return filepath.Join(append([]string{s.root.hostDir}, parts...)...) +} + +// MemoryLocked reports whether the guest's memory — where the client key +// and the token pass through — is locked in RAM and, on Linux, excluded +// from core dumps. See stackencrypt's Client.MemoryLocked for what false +// means and what to do about it. +func (s *ProfileStore) MemoryLocked() bool { return s.root.inst.mem.LockError() == nil } + +// MemoryLockError is why MemoryLocked is false: an error wrapping +// ErrMemoryLock that names what was refused and the limit that refused it. +// Nil while the memory is locked. +func (s *ProfileStore) MemoryLockError() error { + if err := s.root.inst.mem.LockError(); err != nil { + return guest.MemoryLockError(err) + } + return nil +} + +// String prints the store's directory and memory state. Nothing secret. +func (s *ProfileStore) String() string { + return fmt.Sprintf("stackauth.ProfileStore{dir: %s, memory: %s}", s.Dir(), s.root.inst.mem) +} + +// LogValue implements slog.LogValuer: the directory, and the memory state. +func (s *ProfileStore) LogValue() slog.Value { + return slog.GroupValue(slog.String("dir", s.Dir()), slog.Any("memory", s.root.inst.mem.LogValue())) +} + +// Close releases the guest: its shutdown wipes every buffer it still +// holds, and the runtime close wipes and frees its memory. Idempotent. +// Every store of this profile is closed by it; a call on any of them after +// it fails with ErrState. +func (s *ProfileStore) Close() error { + r := s.root + r.mu.Lock() + defer r.mu.Unlock() + if r.released { + return nil + } + r.released = true + r.closed = true + r.cleanup.Stop() + return r.inst.release() +} + +// export selects one of an instance's exports. +type export func(*instance) api.Function + +// call runs one export under the profile's lock, closing the profile if +// the guest trapped or an interrupted call took the module down. +func (s *ProfileStore) call(ctx context.Context, fn export, args ...string) ([]byte, error) { + // Every profile export works on the mount; without one the guest would + // report an I/O error that says less than this does. + if s.root.hostDir == "" { + return nil, ErrNoProfile + } + staged := make([]guest.Arg, 0, len(args)+1) + staged = append(staged, guest.BufArg([]byte(s.dir))) + for _, arg := range args { + staged = append(staged, guest.BufArg([]byte(arg))) + } + return s.callArgs(ctx, fn, staged...) +} + +// callArgs is the shared checked call path for profile exports and auth +// exports. The latter pass secret-bearing byte buffers and wipe host copies. +func (s *ProfileStore) callArgs(ctx context.Context, fn export, args ...guest.Arg) ([]byte, error) { + r := s.root + r.mu.Lock() + defer r.mu.Unlock() + if r.closed || r.inst.module.IsClosed() { + r.closed = true + return nil, ErrState + } + growth := r.inst.mem.GrowthRefusal() + out, err := guest.Call(ctx, r.inst.mem, r.inst.module, r.inst.exports, fn(r.inst), args...) + switch { + case r.inst.module.IsClosed(): + r.closed = true + if err == nil { + err = ErrState + } + err = fmt.Errorf("%w: interrupted call closed the profile", err) + case errors.Is(err, guest.ErrTrap): + r.closed = true + _ = r.inst.module.Close(context.Background()) + err = fmt.Errorf("%w; the profile is closed", err) + } + // Under RequireLockedMemory a growth that cannot be locked is refused, + // and the guest sees only a failed allocation — or, for an allocation + // of its own, aborts, and the trap closed the profile above. Name the + // real cause either way, as stackencrypt's Client.call does. The + // refusal is this call's, not the store's: the range went back unused, + // so MemoryLocked still holds. + if g := r.inst.mem.GrowthRefusal(); err != nil && g.Refused != growth.Refused { + err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", guest.MemoryLockError(g.Reason), err) + } + if err != nil { + return nil, err + } + return out, nil +} + +// CurrentWorkspace is the current workspace id, or ErrNoCurrentWorkspace. +func (s *ProfileStore) CurrentWorkspace(ctx context.Context) (string, error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.currentWorkspace }) + return string(out), err +} + +// SetCurrentWorkspace makes id the current workspace. The workspace must +// already have profile data on this machine (ErrWorkspaceNotFound +// otherwise): a login creates it, this package never does. +func (s *ProfileStore) SetCurrentWorkspace(ctx context.Context, id string) error { + _, err := s.call(ctx, func(i *instance) api.Function { return i.setCurrentWorkspace }, id) + return err +} + +// ClearCurrentWorkspace removes the current workspace selection. Nothing +// to remove is not an error. +func (s *ProfileStore) ClearCurrentWorkspace(ctx context.Context) error { + _, err := s.call(ctx, func(i *instance) api.Function { return i.clearCurrentWorkspace }) + return err +} + +// ListWorkspaces is the workspace ids with profile data on disk, sorted. +func (s *ProfileStore) ListWorkspaces(ctx context.Context) ([]string, error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.listWorkspaces }) + if err != nil { + return nil, err + } + decoded, err := vcffi.Unmarshal(out) + if err != nil { + return nil, fmt.Errorf("%w: decoding the workspace list: %w", ErrInternal, err) + } + items, ok := decoded.([]any) + if !ok { + return nil, fmt.Errorf("%w: the workspace list is not a list", ErrInternal) + } + ids := make([]string, 0, len(items)) + for _, item := range items { + id, ok := item.(string) + if !ok { + return nil, fmt.Errorf("%w: a workspace id is not a string", ErrInternal) + } + ids = append(ids, id) + } + return ids, nil +} + +// WorkspaceStore is the store scoped to workspace id: the directory +// workspaces/<id> under this store, as the guest names it after validating +// the id (ErrInvalidWorkspaceID otherwise). The directory need not exist +// yet; reads from it report ErrNotFound. It shares this store's guest and +// is closed with it. +func (s *ProfileStore) WorkspaceStore(ctx context.Context, id string) (*ProfileStore, error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.workspaceDir }, id) + if err != nil { + return nil, err + } + return &ProfileStore{root: s.root, dir: string(out)}, nil +} + +// CurrentWorkspaceStore is [ProfileStore.WorkspaceStore] for the current +// workspace, or ErrNoCurrentWorkspace. +func (s *ProfileStore) CurrentWorkspaceStore(ctx context.Context) (*ProfileStore, error) { + id, err := s.CurrentWorkspace(ctx) + if err != nil { + return nil, err + } + return s.WorkspaceStore(ctx, id) +} + +// LockPath is the host path of the lock file the Rust crate takes for +// filename in this store: a sibling `.<filename>.lock`. Nothing is created +// or locked. DeviceSession holds this lock across the guest's refresh call; +// the guest cannot lock under WASI. This package never composes a profile +// path itself. +func (s *ProfileStore) LockPath(ctx context.Context, filename string) (string, error) { + // The guest validates the filename as the crate does, against the + // guest's separator, which is `/`. The host's is checked here: on + // Windows a backslash passes the guest and would become a separator + // once the answer is mapped back. And the mapped answer is checked to + // be a direct child of this store before it is returned, so the + // sibling-lock contract holds whatever the guest said. + if filename == "" || filename == "." || filename == ".." || strings.ContainsAny(filename, `/\`) { + return "", fmt.Errorf("%w: %q", ErrInvalidFilename, filename) + } + out, err := s.call(ctx, func(i *instance) api.Function { return i.lockPath }, filename) + if err != nil { + return "", err + } + mapped := s.hostPath(string(out)) + if rel, err := filepath.Rel(s.Dir(), mapped); err != nil || filepath.Dir(rel) != "." || rel == "." || rel == ".." { + return "", fmt.Errorf("%w: %q does not name a file beside %s", ErrInvalidFilename, filename, s.Dir()) + } + return mapped, nil +} + +// SecretKey reads secretkey.json in this store (a workspace store; the +// root holds none): the ZeroKMS client id and the client key, the latter as +// the opaque [ClientKey] stackencrypt.NewCredentials takes. The transport copy +// of the key is wiped once it is in the ClientKey; the key is then the +// caller's to consume. +func (s *ProfileStore) SecretKey(ctx context.Context) (clientID string, key *ClientKey, err error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.secretKey }) + if err != nil { + return "", nil, err + } + defer guest.Wipe(out) + fields, err := object(out) + if err != nil { + return "", nil, err + } + clientID, err = fields.text("client_id") + if err != nil { + return "", nil, err + } + // The key crosses as bytes, not text, so the decoder hands back a + // slice this package owns: the ClientKey takes it, and wipes it when + // it is consumed. No string copy of the material is ever made here. + material, err := fields.bytes("client_key") + if err != nil { + return "", nil, err + } + return clientID, guest.NewClientKey(material), nil +} + +// DeviceIdentity is the identity the CLI created for this machine, read +// from device.json in this store (the profile root). Read-only: creating +// one is the CLI's. +type DeviceIdentity struct { + // DeviceInstanceID uniquely identifies this CLI installation. + DeviceInstanceID string + // DeviceName is a human-readable name, the hostname by default. + DeviceName string +} + +// DeviceIdentity reads device.json in this store. +func (s *ProfileStore) DeviceIdentity(ctx context.Context) (DeviceIdentity, error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.deviceIdentity }) + if err != nil { + return DeviceIdentity{}, err + } + fields, err := object(out) + if err != nil { + return DeviceIdentity{}, err + } + var d DeviceIdentity + if d.DeviceInstanceID, err = fields.text("device_instance_id"); err != nil { + return DeviceIdentity{}, err + } + if d.DeviceName, err = fields.text("device_name"); err != nil { + return DeviceIdentity{}, err + } + return d, nil +} + +// fields is a decoded codec object. +type fields vcvalue.Object + +func object(encoded []byte) (fields, error) { + decoded, err := vcffi.Unmarshal(encoded) + if err != nil { + return nil, fmt.Errorf("%w: decoding the guest's result: %w", ErrInternal, err) + } + obj, ok := decoded.(vcvalue.Object) + if !ok { + return nil, fmt.Errorf("%w: the guest's result is not an object", ErrInternal) + } + return fields(obj), nil +} + +func (f fields) get(key string) (any, bool) { + for _, field := range f { + if field.Key == key { + return field.Value, true + } + } + return nil, false +} + +// text is a string field that must be present. +func (f fields) text(key string) (string, error) { + v, ok := f.get(key) + if !ok { + return "", fmt.Errorf("%w: the guest's result has no %s", ErrInternal, key) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%w: %s is not a string", ErrInternal, key) + } + return s, nil +} + +// bytes is a bytes field that must be present. The decoder allocates the +// slice, so it is the caller's to keep or wipe. +func (f fields) bytes(key string) ([]byte, error) { + v, ok := f.get(key) + if !ok { + return nil, fmt.Errorf("%w: the guest's result has no %s", ErrInternal, key) + } + b, ok := v.([]byte) + if !ok { + return nil, fmt.Errorf("%w: %s is not bytes", ErrInternal, key) + } + return b, nil +} + +// optionalText is a string field that may be null or absent. +func (f fields) optionalText(key string) (string, error) { + v, ok := f.get(key) + if !ok || v == nil { + return "", nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%w: %s is not a string", ErrInternal, key) + } + return s, nil +} + +// uint64Field is an unsigned integer field that must be present. +func (f fields) uint64Field(key string) (uint64, error) { + v, ok := f.get(key) + if !ok { + return 0, fmt.Errorf("%w: the guest's result has no %s", ErrInternal, key) + } + n, ok := v.(uint64) + if !ok { + return 0, fmt.Errorf("%w: %s is not an integer", ErrInternal, key) + } + return n, nil +} diff --git a/languages/golang/stackauth/store_test.go b/languages/golang/stackauth/store_test.go new file mode 100644 index 000000000..a07374065 --- /dev/null +++ b/languages/golang/stackauth/store_test.go @@ -0,0 +1,576 @@ +package stackauth + +import ( + "context" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +const ( + wsA = "AAAAAAAAAAAAAAAA" + wsB = "BBBBBBBBBBBBBBBB" + + secretKeyJSON = `{"client_id":"6a70bd18-99ac-4650-b104-37eec3a15b09","client_key":"AAECAwQFBgc="}` + deviceJSON = `{"device_instance_id":"0f4a4fd7-4a1a-4c5e-9d3e-7a4c8e3a9c11","device_name":"laptop"}` +) + +func authJSON(expiresAt int64) string { + return fmt.Sprintf(`{"access_token":"tok-%d","refresh_token":"refresh","token_type":"Bearer","expires_at":%d,"region":"ap-southeast-2"}`, expiresAt, expiresAt) +} + +// guestOrSkip is the embedded guest, or a skip where it is not built. +func guestOrSkip(t *testing.T) []byte { + t.Helper() + wasm, err := embeddedGuest() + if err != nil { + t.Skip(err) + } + return wasm +} + +// profile is a fresh profile directory with two workspaces and the files a +// login writes into the first, opened as a store. +func profile(t *testing.T) (dir string, s *ProfileStore) { + t.Helper() + guestOrSkip(t) + dir = t.TempDir() + for _, ws := range []string{wsA, wsB} { + if err := os.MkdirAll(filepath.Join(dir, "workspaces", ws), 0o700); err != nil { + t.Fatal(err) + } + } + write(t, filepath.Join(dir, "workspaces", wsA, "secretkey.json"), secretKeyJSON) + write(t, filepath.Join(dir, "workspaces", wsA, "auth.json"), authJSON(time.Now().Add(time.Hour).Unix())) + write(t, filepath.Join(dir, "device.json"), deviceJSON) + s, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + return dir, s +} + +func write(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +// The guest may reach the profile mount and the two named auth host imports. +func TestImportSurfaceIsWASIAndAuthTransport(t *testing.T) { + ctx := context.Background() + r := wazero.NewRuntime(ctx) + defer r.Close(ctx) + compiled, err := r.CompileModule(ctx, guestOrSkip(t)) + if err != nil { + t.Fatal(err) + } + defer compiled.Close(ctx) + sawPathOpen := false + sawTransport := false + sawOIDC := false + for _, imp := range compiled.ImportedFunctions() { + module, name, _ := imp.Import() + if module == "cipherstash_transport" { + switch name { + case "transport_send": + sawTransport = true + case "oidc_token_get": + sawOIDC = true + default: + t.Errorf("unexpected auth host import %s", name) + } + continue + } + if module != "wasi_snapshot_preview1" { + t.Errorf("guest imports %s::%s, outside the allowed surface", module, name) + continue + } + if strings.HasPrefix(name, "sock_") { + t.Errorf("guest imports socket function %s", name) + } + if name == "path_open" { + sawPathOpen = true + } + } + if !sawPathOpen { + t.Error("guest does not import path_open; it cannot be reading a profile") + } + if !sawTransport || !sawOIDC { + t.Errorf("missing auth imports: transport=%t oidc=%t", sawTransport, sawOIDC) + } + for _, name := range []string{"se_alloc", "se_dealloc", "sa_shutdown", "sa_current_workspace", "sa_set_current_workspace", "sa_clear_current_workspace", "sa_list_workspaces", "sa_workspace_dir", "sa_lock_path", "sa_secret_key", "sa_token", "sa_has_token", "sa_device_identity", "sa_auth_new", "sa_auth_validate_crn", "sa_auth_token", "sa_auth_refresh", "sa_auth_free"} { + if _, ok := compiled.ExportedFunctions()[name]; !ok { + t.Errorf("guest does not export %s", name) + } + } +} + +func TestOpenRequiresAnExistingDirectory(t *testing.T) { + guestOrSkip(t) + ctx := context.Background() + if _, err := Open(ctx, filepath.Join(t.TempDir(), "missing")); !errors.Is(err, ErrNoProfile) { + t.Fatalf("Open of a missing directory: %v, want ErrNoProfile", err) + } + file := filepath.Join(t.TempDir(), "file") + write(t, file, "") + if _, err := Open(ctx, file); !errors.Is(err, ErrNoProfile) { + t.Fatalf("Open of a file: %v, want ErrNoProfile", err) + } +} + +// Resolve finds the directory the Rust crate would: CS_CONFIG_PATH first. +func TestResolveHonoursConfigPath(t *testing.T) { + dir, _ := profile(t) + t.Setenv("CS_CONFIG_PATH", dir) + s, err := Resolve(context.Background()) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if s.Dir() != dir { + t.Fatalf("Dir = %q, want %q", s.Dir(), dir) + } + t.Setenv("CS_CONFIG_PATH", " ") + // os.UserHomeDir reads HOME on Unix and USERPROFILE on Windows. + nohome := filepath.Join(t.TempDir(), "nohome") + t.Setenv("HOME", nohome) + t.Setenv("USERPROFILE", nohome) + if _, err := Resolve(context.Background()); !errors.Is(err, ErrNoProfile) { + t.Fatalf("Resolve with a blank CS_CONFIG_PATH and no ~/.cipherstash: %v, want ErrNoProfile", err) + } + // A non-blank value is used as it is, as the crate uses it: a directory + // whose name carries whitespace is the directory it names. (Windows + // trims a trailing space off a directory name itself.) + if runtime.GOOS != "windows" { + spaced := filepath.Join(t.TempDir(), " spaced ") + if err := os.Mkdir(spaced, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("CS_CONFIG_PATH", spaced) + s, err := Resolve(context.Background()) + if err != nil { + t.Fatalf("Resolve with CS_CONFIG_PATH naming a directory with spaces in its name: %v", err) + } + defer s.Close() + if s.Dir() != spaced { + t.Fatalf("Dir = %q, want the value verbatim, %q", s.Dir(), spaced) + } + } +} + +func TestWorkspaceSelectionRoundTripsAndLists(t *testing.T) { + ctx := context.Background() + _, s := profile(t) + if _, err := s.CurrentWorkspace(ctx); !errors.Is(err, ErrNoCurrentWorkspace) { + t.Fatalf("no workspace set: %v, want ErrNoCurrentWorkspace", err) + } + if _, err := s.CurrentWorkspaceStore(ctx); !errors.Is(err, ErrNoCurrentWorkspace) { + t.Fatalf("no workspace set: %v, want ErrNoCurrentWorkspace", err) + } + if err := s.SetCurrentWorkspace(ctx, "CCCCCCCCCCCCCCCC"); !errors.Is(err, ErrWorkspaceNotFound) { + t.Fatalf("setting a workspace with no directory: %v, want ErrWorkspaceNotFound", err) + } + if err := s.SetCurrentWorkspace(ctx, "../escape"); !errors.Is(err, ErrInvalidWorkspaceID) { + t.Fatalf("setting a path as the workspace: %v, want ErrInvalidWorkspaceID", err) + } + if err := s.SetCurrentWorkspace(ctx, wsA); err != nil { + t.Fatal(err) + } + if got, err := s.CurrentWorkspace(ctx); err != nil || got != wsA { + t.Fatalf("CurrentWorkspace = %q, %v; want %q", got, err, wsA) + } + ids, err := s.ListWorkspaces(ctx) + if err != nil || !reflect.DeepEqual(ids, []string{wsA, wsB}) { + t.Fatalf("ListWorkspaces = %v, %v; want [%s %s]", ids, err, wsA, wsB) + } + if err := s.ClearCurrentWorkspace(ctx); err != nil { + t.Fatal(err) + } + if _, err := s.CurrentWorkspace(ctx); !errors.Is(err, ErrNoCurrentWorkspace) { + t.Fatalf("after clearing: %v, want ErrNoCurrentWorkspace", err) + } + if err := s.ClearCurrentWorkspace(ctx); err != nil { + t.Fatalf("clearing twice: %v", err) + } +} + +func TestWorkspaceStoresAreScopedAndShareTheGuest(t *testing.T) { + ctx := context.Background() + dir, s := profile(t) + ws, err := s.WorkspaceStore(ctx, wsA) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(dir, "workspaces", wsA); ws.Dir() != want { + t.Fatalf("workspace Dir = %q, want %q", ws.Dir(), want) + } + if s.Dir() != dir { + t.Fatalf("root Dir = %q, want %q", s.Dir(), dir) + } + if _, err := s.WorkspaceStore(ctx, "not-an-id"); !errors.Is(err, ErrInvalidWorkspaceID) { + t.Fatalf("WorkspaceStore of a bad id: %v, want ErrInvalidWorkspaceID", err) + } + // The root holds no secret key; the workspace does. + if _, _, err := s.SecretKey(ctx); !errors.Is(err, ErrNotFound) { + t.Fatalf("SecretKey at the root: %v, want ErrNotFound", err) + } + if err := s.SetCurrentWorkspace(ctx, wsA); err != nil { + t.Fatal(err) + } + current, err := s.CurrentWorkspaceStore(ctx) + if err != nil || current.Dir() != ws.Dir() { + t.Fatalf("CurrentWorkspaceStore = %v, %v; want %s", current, err, ws.Dir()) + } + // One guest: closing the workspace store closes the profile. + if err := ws.Close(); err != nil { + t.Fatal(err) + } + if _, err := s.CurrentWorkspace(ctx); !errors.Is(err, ErrState) { + t.Fatalf("after Close: %v, want ErrState", err) + } + if err := s.Close(); err != nil { + t.Fatalf("a second Close: %v", err) + } +} + +func TestTypedReadsReturnTheFilesFields(t *testing.T) { + ctx := context.Background() + dir, s := profile(t) + ws, err := s.WorkspaceStore(ctx, wsA) + if err != nil { + t.Fatal(err) + } + + clientID, key, err := ws.SecretKey(ctx) + if err != nil { + t.Fatal(err) + } + if clientID != "6a70bd18-99ac-4650-b104-37eec3a15b09" { + t.Errorf("client id = %q", clientID) + } + if string(guest.KeyBytes(key)) != "AAECAwQFBgc=" { + t.Errorf("client key material = %q, want the file's base64", guest.KeyBytes(key)) + } + if out := fmt.Sprintf("%v %+v %#v %s", key, key, key, key); strings.Contains(out, "AAECAw") { + t.Errorf("the key prints its material: %q", out) + } + key.Wipe() + + tok, err := ws.Token(ctx) + if err != nil { + t.Fatal(err) + } + // The access token is a credential; a failure names what was wrong + // with it rather than printing it. + if !strings.HasPrefix(tok.AccessToken, "tok-") || tok.TokenType != "Bearer" || tok.Region != "ap-southeast-2" { + t.Errorf("Token: access token has the stub's prefix = %t, type = %q, region = %q", + strings.HasPrefix(tok.AccessToken, "tok-"), tok.TokenType, tok.Region) + } + if !tok.Usable(time.Now()) || tok.ExpiresAt.Before(time.Now().Add(50*time.Minute)) { + t.Errorf("ExpiresAt = %s, want about an hour away", tok.ExpiresAt) + } + if tok.ClientID != "" || tok.DeviceInstanceID != "" { + t.Errorf("absent optional fields decoded as client_id = %q, device_instance_id = %q", tok.ClientID, tok.DeviceInstanceID) + } + + identity, err := s.DeviceIdentity(ctx) + if err != nil { + t.Fatal(err) + } + if identity != (DeviceIdentity{DeviceInstanceID: "0f4a4fd7-4a1a-4c5e-9d3e-7a4c8e3a9c11", DeviceName: "laptop"}) { + t.Errorf("DeviceIdentity = %+v", identity) + } + + // The other workspace has nothing: not found, not a trap. + other, err := s.WorkspaceStore(ctx, wsB) + if err != nil { + t.Fatal(err) + } + if _, err := other.Token(ctx); !errors.Is(err, ErrNotFound) { + t.Fatalf("Token of an empty workspace: %v, want ErrNotFound", err) + } + // A malformed file is ErrInvalid. + write(t, filepath.Join(dir, "workspaces", wsB, "auth.json"), "{not json") + if _, err := other.Token(ctx); !errors.Is(err, ErrInvalid) { + t.Fatalf("Token from a malformed file: %v, want ErrInvalid", err) + } +} + +// expires_at is a u64 in the crate's file; one past int64 would wrap to a +// time in the past, so it is refused rather than read as expired. +func TestTokenExpiresAtOutOfRangeIsInternal(t *testing.T) { + ctx := context.Background() + dir, s := profile(t) + write(t, filepath.Join(dir, "workspaces", wsA, "auth.json"), + fmt.Sprintf(`{"access_token":"tok","refresh_token":"refresh","token_type":"Bearer","expires_at":%d}`, uint64(math.MaxInt64)+1)) + ws, err := s.WorkspaceStore(ctx, wsA) + if err != nil { + t.Fatal(err) + } + if _, err := ws.Token(ctx); !errors.Is(err, ErrInternal) { + t.Fatalf("Token: %v, want ErrInternal", err) + } +} + +// The lock file is the crate's sibling `.<filename>.lock`, named by the +// guest and mapped back to the host, never composed here; a filename that +// is a path is refused before any path is built. +func TestLockPathIsTheCratesAndValidated(t *testing.T) { + ctx := context.Background() + dir, s := profile(t) + ws, err := s.WorkspaceStore(ctx, wsA) + if err != nil { + t.Fatal(err) + } + path, err := ws.LockPath(ctx, "auth.json") + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(dir, "workspaces", wsA, ".auth.json.lock"); path != want { + t.Fatalf("LockPath = %q, want %q", path, want) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("naming the lock file created it: %v", err) + } + // The host's separator is refused as well as the guest's: on Windows a + // backslash passes the crate's check (its separator is `/` on wasm32) + // and would become a path once mapped back to the host. + for _, bad := range []string{"", ".", "..", "../auth.json", "/etc/auth.json", "a/b", `a\b`, `x\..\..\outside`} { + if _, err := ws.LockPath(ctx, bad); !errors.Is(err, ErrInvalidFilename) { + t.Errorf("LockPath(%q) = %v, want ErrInvalidFilename", bad, err) + } + } +} + +// Two runtime properties the crate does not own on wasm32 and the package +// therefore pins: a file the guest creates is mode 0600 (wazero's create +// mode; the crate's own mode handling is unix-only and skipped), and the +// guest cannot read outside the one directory it was given. +func TestFilesTheGuestCreatesAreOwnerOnly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no Unix modes on Windows") + } + ctx := context.Background() + dir, s := profile(t) + if err := s.SetCurrentWorkspace(ctx, wsA); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dir, "current_workspace")) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Fatalf("current_workspace is mode %o, want 0600", mode) + } +} + +func TestTheGuestCannotSeeOutsideTheMount(t *testing.T) { + ctx := context.Background() + _, s := profile(t) + // A perfectly good auth.json, outside the directory the guest was + // given. Naming its directory to the guest directly — which no method + // of this package does — must not read it. + outside := t.TempDir() + write(t, filepath.Join(outside, "auth.json"), authJSON(time.Now().Add(time.Hour).Unix())) + escaped := &ProfileStore{root: s.root, dir: outside} + if _, err := escaped.Token(ctx); err == nil { + t.Fatal("the guest read a file outside its mount") + } else if !errors.Is(err, ErrNotFound) && !errors.Is(err, ErrIO) { + t.Fatalf("reading outside the mount: %v, want ErrNotFound or ErrIO", err) + } + // The same through the guest's own root: the mount is the only root + // it has, and `..` above it goes nowhere. + escaped = &ProfileStore{root: s.root, dir: guestRoot + "/.."} + if _, err := escaped.DeviceIdentity(ctx); err == nil { + t.Fatal("the guest read above its mount") + } +} + +// The guest's memory is the shared allocator's: the store reports its lock +// state like a client does, and RequireLockedMemory is honoured. +func TestMemoryStateIsReportedAndStrictIsHonoured(t *testing.T) { + _, s := profile(t) + if s.MemoryLocked() != (s.MemoryLockError() == nil) { + t.Fatal("MemoryLocked and MemoryLockError disagree") + } + if err := s.MemoryLockError(); err != nil && !errors.Is(err, ErrMemoryLock) { + t.Fatalf("MemoryLockError = %v, want ErrMemoryLock or nil", err) + } + if !strings.Contains(fmt.Sprint(s), "memory:") { + t.Fatalf("String = %q, no memory state", s) + } + if !s.MemoryLocked() { + if _, err := Open(context.Background(), s.Dir(), RequireLockedMemory()); !errors.Is(err, ErrMemoryLock) { + t.Fatalf("RequireLockedMemory under a refused lock: %v, want ErrMemoryLock", err) + } + } +} + +// A ProfileStore that becomes unreachable without Close is released by +// its cleanup, as a Client is. +func TestUnreachableStoreIsReleased(t *testing.T) { + dir, _ := profile(t) + s, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + alloc := s.root.inst.mem + s = nil + deadline := time.Now().Add(10 * time.Second) + for !alloc.IsFreed() { + if time.Now().After(deadline) { + t.Fatal("an unreachable store's memory was not released") + } + runtime.GC() + time.Sleep(10 * time.Millisecond) + } +} + +// The guest root this package mounts at is the one the guest builds every +// path under. +func TestGuestRootMatchesTheGuest(t *testing.T) { + ctx := context.Background() + _, s := profile(t) + ws, err := s.WorkspaceStore(ctx, wsA) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(ws.dir, guestRoot+"/") { + t.Fatalf("the guest named %q, not under %q", ws.dir, guestRoot) + } + if guest.PolicyFor(false) != guest.BestEffort { + t.Fatal("the default policy is not best effort") + } +} + +// The mount is confined to the directory it names, symlinks included: a +// symlink inside the profile that leads outside it is refused, for a read +// and for the one write the guest makes, while a symlink that stays inside +// is the file it names. wazero's own directory mount would follow all of +// them with the process's permissions. +func TestASymlinkOutOfTheMountIsRefused(t *testing.T) { + ctx := context.Background() + dir, s := profile(t) + outside := t.TempDir() + symlink := func(target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create a symlink here: %v", err) + } + } + + // A read through a symlinked file: device.json leads outside. + elsewhere := filepath.Join(outside, "device.json") + write(t, elsewhere, deviceJSON) + if err := os.Remove(filepath.Join(dir, "device.json")); err != nil { + t.Fatal(err) + } + symlink(elsewhere, filepath.Join(dir, "device.json")) + if _, err := s.DeviceIdentity(ctx); err == nil { + t.Fatal("the guest read a file outside the mount through a symlink") + } else if !errors.Is(err, ErrIO) { + t.Fatalf("reading through an escaping symlink: %v, want ErrIO", err) + } + + // A read through a symlinked directory: workspaces/<id> leads outside. + escapedWorkspace := filepath.Join(outside, "ws") + if err := os.Mkdir(escapedWorkspace, 0o700); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(escapedWorkspace, "auth.json"), authJSON(time.Now().Add(time.Hour).Unix())) + const wsC = "CCCCCCCCCCCCCCCC" + symlink(escapedWorkspace, filepath.Join(dir, "workspaces", wsC)) + ws, err := s.WorkspaceStore(ctx, wsC) + if err != nil { + t.Fatal(err) + } + if _, err := ws.Token(ctx); !errors.Is(err, ErrIO) { + t.Fatalf("reading through an escaping symlinked directory: %v, want ErrIO", err) + } + + // The one write, through a symlinked file: current_workspace leads to + // a file outside, which must be left as it was. + victim := filepath.Join(outside, "victim") + write(t, victim, "untouched") + symlink(victim, filepath.Join(dir, "current_workspace")) + if err := s.SetCurrentWorkspace(ctx, wsA); !errors.Is(err, ErrIO) { + t.Fatalf("writing through an escaping symlink: %v, want ErrIO", err) + } + if got, err := os.ReadFile(victim); err != nil || string(got) != "untouched" { + t.Fatalf("the guest wrote outside the mount through a symlink: %q, %v", got, err) + } + + // A symlink that stays inside the mount is the directory it names. + const wsD = "DDDDDDDDDDDDDDDD" + symlink(wsA, filepath.Join(dir, "workspaces", wsD)) + same, err := s.WorkspaceStore(ctx, wsD) + if err != nil { + t.Fatal(err) + } + if _, err := same.Token(ctx); err != nil { + t.Fatalf("reading through a symlink that stays inside the mount: %v", err) + } +} + +// The profile directory itself may be a symlink — a dotfiles manager's +// usual arrangement — and is opened as the directory it names. +func TestAProfileDirectoryThatIsASymlinkOpens(t *testing.T) { + ctx := context.Background() + dir, _ := profile(t) + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(dir, link); err != nil { + t.Skipf("cannot create a symlink here: %v", err) + } + s, err := Open(ctx, link) + if err != nil { + t.Fatalf("Open of a symlinked profile directory: %v", err) + } + defer s.Close() + if _, err := s.DeviceIdentity(ctx); err != nil { + t.Fatalf("reading through a symlinked profile directory: %v", err) + } +} + +// Under RequireLockedMemory a growth the lock limit refuses is reported as +// ErrMemoryLock naming the refusal, as a client reports it, and the store +// stays open with its lock report as it was: the range went back unused. +// (The report is whatever this host gave at Open — locked, or the heap +// fallback's refusal on a 32-bit host — and must not move.) +func TestARefusedGrowthIsReportedAsMemoryLock(t *testing.T) { + ctx := context.Background() + _, s := profile(t) + before := s.MemoryLockError() + refusing := guest.RefuseGrowth(s.root.inst.mem, errors.New("refused for the test")) + // Staging a 2 MiB argument into guest memory needs a growth, before the + // guest can refuse it as a workspace id. + huge := strings.Repeat("A", 2<<20) + _, err := s.call(ctx, func(i *instance) api.Function { return i.setCurrentWorkspace }, huge) + if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "growth refused") || !strings.Contains(err.Error(), refusing.Reason().Error()) { + t.Fatalf("a call needing a refused growth: %v; want ErrMemoryLock naming the refusal", err) + } + if refusing.Refused() == 0 { + t.Fatal("the guest did not grow; the test proves nothing") + } + if after := s.MemoryLockError(); fmt.Sprint(after) != fmt.Sprint(before) { + t.Fatalf("a refused growth changed the lock report: %v -> %v", before, after) + } + // The store is still open, and grows once it can. + refusing.Allow() + if _, err := s.CurrentWorkspace(ctx); !errors.Is(err, ErrNoCurrentWorkspace) { + t.Fatalf("the next call, growth allowed: %v", err) + } +} diff --git a/languages/golang/stackauth/strategy.go b/languages/golang/stackauth/strategy.go new file mode 100644 index 000000000..a5b0d9660 --- /dev/null +++ b/languages/golang/stackauth/strategy.go @@ -0,0 +1,230 @@ +package stackauth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "sync" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/tetratelabs/wazero/api" +) + +// StrategyOption configures an auth strategy. +type StrategyOption func(*strategyOptions) + +type strategyOptions struct{ baseURL string } + +// WithAuthBaseURL overrides CTS service discovery for one strategy. +func WithAuthBaseURL(url string) StrategyOption { + return func(o *strategyOptions) { o.baseURL = url } +} + +func strategyConfig(opts []StrategyOption) strategyOptions { + var o strategyOptions + for _, opt := range opts { + opt(&o) + } + if o.baseURL == "" { + o.baseURL = os.Getenv("CS_CTS_HOST") + } + return o +} + +// Strategy is a Rust stack-auth strategy retained inside the credential +// guest: the source of the bearer token stackencrypt.NewCredentials takes. +// Close drops its cached credential; closing the parent profile closes all +// its strategies. It is the caller's to close: a stackencrypt client that +// was given it asks it for tokens but never closes it, so it must stay open +// until the client is closed. +type Strategy struct { + store *ProfileStore + handle string + providerID uint32 + device bool + mu sync.Mutex + closed bool +} + +func (s *ProfileStore) newStrategy(ctx context.Context, config any, device bool) (*Strategy, error) { + data, err := json.Marshal(config) + if err != nil { + return nil, fmt.Errorf("stackauth: encode strategy: %w", err) + } + defer guest.Wipe(data) + out, err := s.callArgs(ctx, func(i *instance) api.Function { return i.authNew }, guest.BufArg(data)) + if err != nil { + return nil, err + } + if _, err := strconv.ParseUint(string(out), 10, 32); err != nil { + return nil, fmt.Errorf("%w: invalid auth handle", ErrInternal) + } + return &Strategy{store: s, handle: string(out), device: device}, nil +} + +// AccessKey constructs stack-auth's access-key strategy for crn. The key +// stays in the guest after construction; it is not sent on each Token call. +func (s *ProfileStore) AccessKey(ctx context.Context, crn, key string, opts ...StrategyOption) (*Strategy, error) { + if crn == "" || key == "" { + return nil, ErrAuthConfig + } + o := strategyConfig(opts) + return s.newStrategy(ctx, struct { + Kind string `json:"kind"` + CRN string `json:"crn"` + Key string `json:"access_key"` + BaseURL string `json:"base_url,omitempty"` + }{"access_key", crn, key, o.baseURL}, false) +} + +// OIDC constructs stack-auth's federation strategy. provider is called for +// a fresh IdP JWT only when the strategy needs to mint a CTS token. +func (s *ProfileStore) OIDC(ctx context.Context, crn string, provider OIDCProvider, opts ...StrategyOption) (*Strategy, error) { + if crn == "" || provider == nil { + return nil, ErrAuthConfig + } + o := strategyConfig(opts) + id := s.root.inst.transport.register(provider) + config := struct { + Kind string `json:"kind"` + CRN string `json:"crn"` + Provider uint32 `json:"provider"` + BaseURL string `json:"base_url,omitempty"` + }{"oidc", crn, id, o.baseURL} + strategy, err := s.newStrategy(ctx, config, false) + if err != nil { + s.root.inst.transport.unregister(id) + return nil, err + } + strategy.providerID = id + return strategy, nil +} + +// DeviceSession uses this workspace store's auth.json. Fresh tokens are read +// without a lock. A refresh takes the same cross-process lock as the Rust +// CLI, then the guest re-reads and saves before the lock is released. +func (s *ProfileStore) DeviceSession(ctx context.Context, opts ...StrategyOption) (*Strategy, error) { + if s.dir == guestRoot { + return nil, ErrAuthConfig + } + o := strategyConfig(opts) + return s.newStrategy(ctx, struct { + Kind string `json:"kind"` + Dir string `json:"workspace_dir"` + BaseURL string `json:"base_url,omitempty"` + }{"device_session", s.dir, o.baseURL}, true) +} + +// Auto follows stack-auth's detection order against the Go host's +// environment: access key first, then the current workspace's device token. +func (s *ProfileStore) Auto(ctx context.Context, opts ...StrategyOption) (*Strategy, error) { + crn, crnSet := os.LookupEnv("CS_WORKSPACE_CRN") + if crnSet { + _, err := s.callArgs(ctx, func(i *instance) api.Function { return i.authValidateCRN }, guest.BufArg([]byte(crn))) + if err != nil { + return nil, err + } + } + if key, keySet := os.LookupEnv("CS_CLIENT_ACCESS_KEY"); keySet { + if !crnSet { + return nil, ErrAuthConfig + } + return s.AccessKey(ctx, crn, key, opts...) + } + workspace, err := s.CurrentWorkspaceStore(ctx) + if err != nil { + if errors.Is(err, ErrNoCurrentWorkspace) || errors.Is(err, ErrNoProfile) { + return nil, ErrNotAuthenticated + } + return nil, err + } + hasToken, err := workspace.hasToken(ctx) + if err != nil { + return nil, err + } + if !hasToken { + return nil, ErrNotAuthenticated + } + return workspace.DeviceSession(ctx, opts...) +} + +func (s *ProfileStore) hasToken(ctx context.Context) (bool, error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.hasToken }) + if err != nil { + return false, err + } + if len(out) != 1 || out[0] > 1 { + return false, fmt.Errorf("%w: invalid has-token response", ErrInternal) + } + return out[0] == 1, nil +} + +// Token gets the current CTS bearer credential. Device sessions read a fresh +// token without a file lock; only a refresh call takes the cross-process lock. +func (s *Strategy) Token(ctx context.Context) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return "", ErrState + } + ctx, status := withAuthHTTPStatus(ctx) + token, err := s.token(ctx) + return token, status.wrap(err) +} + +// token is Token under the strategy's lock: a read, then a locked refresh +// for a device session that needs one. +func (s *Strategy) token(ctx context.Context) (string, error) { + call := func(export func(*instance) api.Function) (string, error) { + out, err := s.store.callArgs(ctx, export, guest.BufArg([]byte(s.handle))) + if err != nil { + return "", err + } + defer guest.Wipe(out) + return string(out), nil + } + token, err := call(func(i *instance) api.Function { return i.authToken }) + if !s.device || !errors.Is(err, guest.ErrAuthRefreshRequired) { + return token, err + } + path, err := s.store.LockPath(ctx, "auth.json") + if err != nil { + return "", err + } + err = withRefreshLock(ctx, path, func() error { + var err error + token, err = call(func(i *instance) api.Function { return i.authRefresh }) + return err + }) + return token, err +} + +// MemoryLockError is why the memory the strategy lives in is not locked in +// RAM: its ProfileStore's guest, where its token, its access key or refresh +// token, and any client key read through the same store are held. It is +// [ProfileStore.MemoryLockError] of that store, asked now rather than when +// the strategy was made: under best-effort locking the guest can commit +// unlocked memory later, on a growth for a token exchange or a refresh. +// Nil while the memory is locked. It can be asked after Close. +func (s *Strategy) MemoryLockError() error { return s.store.MemoryLockError() } + +// Close drops the strategy's cached token and unregisters its provider. +func (s *Strategy) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil + } + s.closed = true + if s.providerID != 0 { + s.store.root.inst.transport.unregister(s.providerID) + } + _, err := s.store.callArgs(context.Background(), func(i *instance) api.Function { return i.authFree }, guest.BufArg([]byte(s.handle))) + if errors.Is(err, ErrState) { + return nil + } + return err +} diff --git a/languages/golang/stackauth/strategy_test.go b/languages/golang/stackauth/strategy_test.go new file mode 100644 index 000000000..207b0553d --- /dev/null +++ b/languages/golang/stackauth/strategy_test.go @@ -0,0 +1,701 @@ +package stackauth + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/tetratelabs/wazero/api" +) + +const testCRN = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" + +func testJWT(t *testing.T, issuer string) string { + t.Helper() + payload, err := json.Marshal(map[string]any{ + "iss": issuer, "sub": "CS|test", "workspace": "ZVATKW3VHMFG27DY", + "exp": time.Now().Add(time.Hour).Unix(), + }) + if err != nil { + t.Fatal(err) + } + return "e30." + base64.RawURLEncoding.EncodeToString(payload) + ".c2ln" +} + +func TestAccessKeyStrategyCachesAndPreservesRequest(t *testing.T) { + guestOrSkip(t) + var calls atomic.Int32 + var jwt string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != http.MethodPost || r.URL.Path != "/api/authorise" { + t.Errorf("request: %s %s", r.Method, r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if want := (map[string]any{"accessKey": "CSAKtestKeyId.testKeySecret"}); !reflect.DeepEqual(body, want) { + t.Errorf("request body = %#v, want %#v", body, want) + } + fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, jwt, time.Now().Add(time.Hour).Unix()) + })) + defer server.Close() + jwt = testJWT(t, server.URL) + profile, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + for i := 0; i < 2; i++ { + got, err := strategy.Token(context.Background()) + if err != nil || got != jwt { + t.Fatalf("Token #%d = %q, %v", i, got, err) + } + } + if calls.Load() != 1 { + t.Fatalf("auth requests = %d, want 1 cached exchange", calls.Load()) + } +} + +func TestOIDCStrategyCallsProviderOnlyOnExchange(t *testing.T) { + guestOrSkip(t) + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if want := (map[string]any{"oidcToken": "idp-token", "workspaceId": "ZVATKW3VHMFG27DY"}); !reflect.DeepEqual(body, want) { + t.Errorf("request body = %#v, want %#v", body, want) + } + if ua := r.Header.Get("User-Agent"); !isStackAuthGoAgent(ua) { + t.Errorf("OIDC federation User-Agent = %q, want stack-auth/<version> (Go)", ua) + } + fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, testJWT(t, "https://cts.example"), time.Now().Add(time.Hour).Unix()) + })) + defer server.Close() + profile, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.OIDC(context.Background(), testCRN, OIDCProviderFunc(func(context.Context) (string, error) { + calls.Add(1) + return "idp-token", nil + }), WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + for i := 0; i < 2; i++ { + if _, err := strategy.Token(context.Background()); err != nil { + t.Fatal(err) + } + } + if calls.Load() != 1 { + t.Fatalf("provider calls = %d, want 1", calls.Load()) + } +} + +func TestUsageLimitIsPreservedAcrossGuest(t *testing.T) { + guestOrSkip(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusPaymentRequired) + fmt.Fprint(w, `{"cs_code":"USAGE_LIMIT_EXCEEDED"}`) + })) + defer server.Close() + profile, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + _, err = strategy.Token(context.Background()) + if !errors.Is(err, ErrUsageLimit) { + t.Fatalf("Token error = %v, want %v", err, ErrUsageLimit) + } +} + +func TestDeviceRefreshReportsInvalidClient(t *testing.T) { + guestOrSkip(t) + dir, _ := expiredDeviceProfile(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"invalid_client"}`) + })) + defer server.Close() + profile, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + ws, err := profile.WorkspaceStore(context.Background(), wsA) + if err != nil { + t.Fatal(err) + } + strategy, err := ws.DeviceSession(context.Background(), WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + _, err = strategy.Token(context.Background()) + if !errors.Is(err, ErrInvalidClient) { + t.Fatalf("Token error = %v, want %v", err, ErrInvalidClient) + } +} + +// Match stack-auth's AutoStrategy order: an access key wins over a stored +// device session; with no key, the current workspace's auth.json is used. +func TestAutoPrefersAccessKeyThenDeviceSession(t *testing.T) { + guestOrSkip(t) + dir, _ := expiredDeviceProfile(t) + var accessCalls, refreshCalls atomic.Int32 + var accessJWT string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/authorise": + accessCalls.Add(1) + fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, accessJWT, time.Now().Add(time.Hour).Unix()) + case "/oauth/token": + refreshCalls.Add(1) + fmt.Fprint(w, `{"access_token":"device-token","token_type":"Bearer","expires_in":3600,"refresh_token":"refresh-2"}`) + default: + t.Errorf("unexpected auth path %q", r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + accessJWT = testJWT(t, server.URL) + profile, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + if err := profile.SetCurrentWorkspace(context.Background(), wsA); err != nil { + t.Fatal(err) + } + t.Setenv("CS_CLIENT_ACCESS_KEY", "CSAKtestKeyId.testKeySecret") + t.Setenv("CS_WORKSPACE_CRN", testCRN) + strategy, err := profile.Auto(context.Background(), WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + token, err := strategy.Token(context.Background()) + if err != nil || token != accessJWT { + t.Fatalf("access key: token %q, error %v", token, err) + } + strategy.Close() + if accessCalls.Load() != 1 || refreshCalls.Load() != 0 { + t.Fatalf("access key should win: access=%d refresh=%d", accessCalls.Load(), refreshCalls.Load()) + } + if err := os.Unsetenv("CS_CLIENT_ACCESS_KEY"); err != nil { + t.Fatal(err) + } + strategy, err = profile.Auto(context.Background(), WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + token, err = strategy.Token(context.Background()) + if err != nil || token != "device-token" { + t.Fatalf("device fallback: token %q, error %v", token, err) + } + if refreshCalls.Load() != 1 { + t.Fatalf("device refresh requests = %d, want 1", refreshCalls.Load()) + } + if err := profile.ClearCurrentWorkspace(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := profile.Auto(context.Background(), WithAuthBaseURL(server.URL)); !errors.Is(err, ErrNotAuthenticated) { + t.Fatalf("no credentials: error = %v, want %v", err, ErrNotAuthenticated) + } +} + +func expiredDeviceProfile(t *testing.T) (string, string) { + t.Helper() + dir := t.TempDir() + workspaceDir := filepath.Join(dir, "workspaces", wsA) + if err := os.MkdirAll(workspaceDir, 0o700); err != nil { + t.Fatal(err) + } + data := fmt.Sprintf(`{"access_token":"old","refresh_token":"refresh-1","token_type":"Bearer","expires_at":%d,"region":"ap-southeast-2.aws","client_id":"client-1"}`, time.Now().Add(-time.Hour).Unix()) + write(t, filepath.Join(workspaceDir, "auth.json"), data) + return dir, workspaceDir +} + +func TestDeviceSessionFreshTokenDoesNotTakeRefreshLock(t *testing.T) { + guestOrSkip(t) + dir, workspaceDir := expiredDeviceProfile(t) + write(t, filepath.Join(workspaceDir, "auth.json"), fmt.Sprintf(`{"access_token":"fresh","refresh_token":"refresh-1","token_type":"Bearer","expires_at":%d,"region":"ap-southeast-2.aws","client_id":"client-1"}`, time.Now().Add(time.Hour).Unix())) + profile, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + workspace, err := profile.WorkspaceStore(context.Background(), wsA) + if err != nil { + t.Fatal(err) + } + strategy, err := workspace.DeviceSession(context.Background(), WithAuthBaseURL("https://cts.example.com")) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + path, err := workspace.LockPath(context.Background(), "auth.json") + if err != nil { + t.Fatal(err) + } + err = withRefreshLock(context.Background(), path, func() error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + token, err := strategy.Token(ctx) + if err != nil || token != "fresh" { + return fmt.Errorf("fresh token while lock is held = %q, %v", token, err) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestDeviceSessionMissingAndInvalidProfilesKeepTheirErrors(t *testing.T) { + guestOrSkip(t) + for _, tc := range []struct { + name string + body string + want error + }{ + {"missing", "", ErrNotFound}, + {"invalid JSON", "{", ErrInvalid}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + workspaceDir := filepath.Join(dir, "workspaces", wsA) + if err := os.MkdirAll(workspaceDir, 0o700); err != nil { + t.Fatal(err) + } + if tc.body != "" { + write(t, filepath.Join(workspaceDir, "auth.json"), tc.body) + } + profile, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + workspace, err := profile.WorkspaceStore(context.Background(), wsA) + if err != nil { + t.Fatal(err) + } + strategy, err := workspace.DeviceSession(context.Background(), WithAuthBaseURL("https://cts.example.com")) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + if _, err := strategy.Token(context.Background()); !errors.Is(err, tc.want) { + t.Fatalf("Token error = %v, want %v", err, tc.want) + } + }) + } +} + +func TestAutoUsesEnvironmentPresenceAndProfileExistence(t *testing.T) { + guestOrSkip(t) + dir, workspaceDir := expiredDeviceProfile(t) + profile, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + if err := profile.SetCurrentWorkspace(context.Background(), wsA); err != nil { + t.Fatal(err) + } + t.Setenv("CS_WORKSPACE_CRN", testCRN) + t.Setenv("CS_CLIENT_ACCESS_KEY", "") + if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrAuthConfig) { + t.Fatalf("set but empty access key: error = %v, want %v", err, ErrAuthConfig) + } + // A key that does not parse is a configuration error like the empty one, + // the class Rust's AutoStrategy reports, not a malformed-input error. + t.Setenv("CS_CLIENT_ACCESS_KEY", "not-a-key") + if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrAuthConfig) { + t.Fatalf("malformed access key: error = %v, want %v", err, ErrAuthConfig) + } + if _, err := profile.AccessKey(context.Background(), "invalid", "CSAKtestKeyId.testKeySecret"); !errors.Is(err, ErrAuthConfig) { + t.Fatalf("malformed CRN for access key: error = %v, want %v", err, ErrAuthConfig) + } + provider := OIDCProviderFunc(func(context.Context) (string, error) { return "", nil }) + if _, err := profile.OIDC(context.Background(), "invalid", provider); !errors.Is(err, ErrAuthConfig) { + t.Fatalf("malformed CRN for OIDC: error = %v, want %v", err, ErrAuthConfig) + } + if err := os.Unsetenv("CS_CLIENT_ACCESS_KEY"); err != nil { + t.Fatal(err) + } + t.Setenv("CS_WORKSPACE_CRN", "invalid") + if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrAuthConfig) { + t.Fatalf("invalid CRN without key: error = %v, want %v", err, ErrAuthConfig) + } + if err := os.Unsetenv("CS_WORKSPACE_CRN"); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(workspaceDir, "auth.json"), "{") + strategy, err := profile.Auto(context.Background()) + if err != nil { + t.Fatalf("existing but invalid profile must select device strategy: %v", err) + } + defer strategy.Close() + if _, err := strategy.Token(context.Background()); !errors.Is(err, ErrInvalid) { + t.Fatalf("invalid profile: error = %v, want %v", err, ErrInvalid) + } +} + +func TestDeviceRefreshLockPreventsReplay(t *testing.T) { + guestOrSkip(t) + dir, _ := expiredDeviceProfile(t) + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Path != "/oauth/token" { + t.Errorf("path: %s", r.URL.Path) + } + if ua := r.Header.Get("User-Agent"); !isStackAuthGoAgent(ua) { + t.Errorf("device-session refresh User-Agent = %q, want stack-auth/<version> (Go)", ua) + } + if err := r.ParseForm(); err != nil { + t.Error(err) + } + want := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {"refresh-1"}, "client_id": {"client-1"}} + if !reflect.DeepEqual(r.PostForm, want) { + t.Errorf("request body = %v, want %v", r.PostForm, want) + } + time.Sleep(50 * time.Millisecond) + fmt.Fprint(w, `{"access_token":"fresh","token_type":"Bearer","expires_in":3600,"refresh_token":"refresh-2"}`) + })) + defer server.Close() + var wg sync.WaitGroup + errCh := make(chan error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + profile, err := Open(context.Background(), dir) + if err != nil { + errCh <- err + return + } + defer profile.Close() + ws, err := profile.WorkspaceStore(context.Background(), wsA) + if err != nil { + errCh <- err + return + } + strategy, err := ws.DeviceSession(context.Background(), WithAuthBaseURL(server.URL)) + if err != nil { + errCh <- err + return + } + defer strategy.Close() + token, err := strategy.Token(context.Background()) + if err == nil && token != "fresh" { + err = fmt.Errorf("token = %q, want fresh", token) + } + errCh <- err + }() + } + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + t.Error(err) + } + } + if calls.Load() != 1 { + t.Fatalf("refresh requests = %d, want 1", calls.Load()) + } + data, err := os.ReadFile(filepath.Join(dir, "workspaces", wsA, "auth.json")) + if err != nil { + t.Fatal(err) + } + var stored map[string]any + if err := json.Unmarshal(data, &stored); err != nil { + t.Fatal(err) + } + if stored["refresh_token"] != "refresh-2" { + t.Fatalf("stored refresh token = %v", stored["refresh_token"]) + } +} + +func TestDeviceRefreshReportsInvalidGrant(t *testing.T) { + guestOrSkip(t) + dir, _ := expiredDeviceProfile(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"invalid_grant"}`) + })) + defer server.Close() + profile, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + ws, err := profile.WorkspaceStore(context.Background(), wsA) + if err != nil { + t.Fatal(err) + } + strategy, err := ws.DeviceSession(context.Background(), WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + _, err = strategy.Token(context.Background()) + if !errors.Is(err, ErrInvalidGrant) { + t.Fatalf("Token error = %v, want ErrInvalidGrant", err) + } +} + +// The edge in front of production CTS answers a request whose User-Agent is +// Go's default (Go-http-client/1.1) with a bare nginx 403 before CTS sees +// it, and Go's HTTP client fills that default in when a request carries +// none. The access-key exchange must name stack-auth and the Go host. +func TestAuthRequestsIdentifyTheLibraryNotGo(t *testing.T) { + guestOrSkip(t) + var agent atomic.Value + var jwt string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + agent.Store(r.Header.Get("User-Agent")) + if ua := r.Header.Get("User-Agent"); ua == "" || strings.HasPrefix(ua, "Go-http-client/") { + // What the production edge does, so the failure is the real one. + http.Error(w, "<html><center><h1>403 Forbidden</h1></center></html>", http.StatusForbidden) + return + } + fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, jwt, time.Now().Add(time.Hour).Unix()) + })) + defer server.Close() + jwt = testJWT(t, server.URL) + profile, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + got, err := strategy.Token(context.Background()) + ua, _ := agent.Load().(string) + if err != nil || got != jwt { + t.Fatalf("Token = %q, %v (User-Agent %q)", got, err, ua) + } + if !isStackAuthGoAgent(ua) { + t.Fatalf("User-Agent = %q, want stack-auth/<version> (Go)", ua) + } +} + +// isStackAuthGoAgent reports whether ua is the credential guest's own, +// stack-auth/<version> (Go), and not Go's default Go-http-client/1.1. +func isStackAuthGoAgent(ua string) bool { + version, ok := strings.CutPrefix(ua, "stack-auth/") + if !ok { + return false + } + version, ok = strings.CutSuffix(version, " (Go)") + return ok && version != "" && !strings.ContainsAny(version, " ()") +} + +// Only a status code crosses the guest ABI, so a refused exchange must still +// say which HTTP status refused it, and never carry the response body. +func TestAuthTransportErrorNamesTheHTTPStatusNotTheBody(t *testing.T) { + guestOrSkip(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, "<html><h1>403 Forbidden</h1>nginx CSAKtestKeyId.testKeySecret</html>") + })) + defer server.Close() + profile, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + _, err = strategy.Token(context.Background()) + if !errors.Is(err, ErrAuthTransport) { + t.Fatalf("Token error = %v, want ErrAuthTransport", err) + } + if want := "cipherstash: auth transport failed: HTTP 403"; err.Error() != want { + t.Fatalf("Token error = %q, want %q", err, want) + } +} + +// A transport failure with no HTTP response at all stays the bare sentinel: +// there is no status to name. +func TestAuthTransportErrorWithoutAResponseNamesNoStatus(t *testing.T) { + guestOrSkip(t) + server := httptest.NewServer(http.NotFoundHandler()) + addr := server.URL + server.Close() + profile, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(addr)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + _, err = strategy.Token(context.Background()) + if !errors.Is(err, ErrAuthTransport) || strings.Contains(err.Error(), "HTTP") { + t.Fatalf("Token error = %v, want a bare ErrAuthTransport", err) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// A status that would wrap in the guest's i32 — here to 200 — is refused +// as a transport failure, so a failed exchange cannot pass as a success. +func TestOutOfRangeAuthStatusIsTransport(t *testing.T) { + guestOrSkip(t) + cases := map[string]int{"negative": -200, "two digits": 99, "four digits": 1000} + if strconv.IntSize == 64 { + wraps := int64(1<<32 + 200) + cases["wraps to 200"] = int(wraps) + } + for name, status := range cases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + rt := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"accessToken":"x","expiry":0}`)), + }, nil + }) + profile, err := Open(ctx, t.TempDir(), WithRoundTripper(rt)) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(ctx, testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL("https://cts.invalid")) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + if _, err := strategy.Token(ctx); !errors.Is(err, ErrAuthTransport) { + t.Fatalf("Token: %v, want ErrAuthTransport", err) + } + }) + } +} + +// A store with no profile mounted still runs the strategies that need none: +// the environment's access key through Auto, as stack-auth's AutoStrategy +// does with no profile store. Profile reads are ErrNoProfile, and Auto with +// no access key is ErrNotAuthenticated rather than a profile error. +func TestOpenWithoutProfileRunsAccessKeyAndRefusesProfileReads(t *testing.T) { + guestOrSkip(t) + var jwt string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, jwt, time.Now().Add(time.Hour).Unix()) + })) + defer server.Close() + jwt = testJWT(t, server.URL) + ctx := context.Background() + store, err := OpenWithoutProfile(ctx) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if store.Dir() != "" { + t.Errorf("Dir = %q, want empty", store.Dir()) + } + if _, err := store.CurrentWorkspace(ctx); !errors.Is(err, ErrNoProfile) { + t.Errorf("CurrentWorkspace: %v, want ErrNoProfile", err) + } + if _, _, err := store.SecretKey(ctx); !errors.Is(err, ErrNoProfile) { + t.Errorf("SecretKey: %v, want ErrNoProfile", err) + } + t.Setenv("CS_CLIENT_ACCESS_KEY", "CSAKtestKeyId.testKeySecret") + t.Setenv("CS_WORKSPACE_CRN", testCRN) + strategy, err := store.Auto(ctx, WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + if token, err := strategy.Token(ctx); err != nil || token != jwt { + t.Fatalf("Token = %q, %v", token, err) + } + if err := os.Unsetenv("CS_CLIENT_ACCESS_KEY"); err != nil { + t.Fatal(err) + } + if _, err := store.Auto(ctx); !errors.Is(err, ErrNotAuthenticated) { + t.Fatalf("Auto with no key and no profile: %v, want ErrNotAuthenticated", err) + } +} + +// A strategy reports its store's memory lock, asked live: a store opened +// best effort whose guest later grows into memory it cannot lock is +// reported unlocked by its strategies from then on. +func TestStrategyReportsItsStoresMemoryLockLive(t *testing.T) { + ctx := context.Background() + _, s := profile(t) + strategy, err := s.AccessKey(ctx, testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL("https://cts.invalid")) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + if fmt.Sprint(strategy.MemoryLockError()) != fmt.Sprint(s.MemoryLockError()) { + t.Fatalf("Strategy.MemoryLockError = %v, want the store's %v", strategy.MemoryLockError(), s.MemoryLockError()) + } + if s.MemoryLockError() != nil { + t.Skipf("the store is unlocked already here (%v); a later refusal cannot be told apart", s.MemoryLockError()) + } + unlocked := guest.UnlockGrowth(s.root.inst.mem, errors.New("refused for the test")) + // Staging a 2 MiB argument into guest memory needs a growth. + if _, err := s.call(ctx, func(i *instance) api.Function { return i.setCurrentWorkspace }, strings.Repeat("A", 2<<20)); errors.Is(err, ErrMemoryLock) { + t.Fatalf("a best-effort growth was refused: %v", err) + } + if unlocked.Refused() == 0 { + t.Fatal("the guest did not grow; the test proves nothing") + } + if err := strategy.MemoryLockError(); !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), unlocked.Reason().Error()) { + t.Fatalf("Strategy.MemoryLockError after an unlocked growth = %v, want ErrMemoryLock naming it", err) + } + _ = strategy.Close() + if err := strategy.MemoryLockError(); !errors.Is(err, ErrMemoryLock) { + t.Fatalf("Strategy.MemoryLockError after Close = %v, want the store's report still", err) + } +} diff --git a/languages/golang/stackauth/token.go b/languages/golang/stackauth/token.go new file mode 100644 index 000000000..49dccbb3c --- /dev/null +++ b/languages/golang/stackauth/token.go @@ -0,0 +1,75 @@ +package stackauth + +import ( + "context" + "fmt" + "math" + "time" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/tetratelabs/wazero/api" +) + +// Token is the stored access token, as auth.json holds it and +// [ProfileStore.Token] reads it through the Rust crate's own type. The +// refresh token is not in it: the guest handles refresh through a Strategy. +type Token struct { + // AccessToken is the bearer credential. + AccessToken string + // TokenType is the token's type, "Bearer". + TokenType string + // ExpiresAt is when the token stops being usable, from the stored + // epoch timestamp. + ExpiresAt time.Time + // Region is the region the token was issued for, if stored. + Region string + // ClientID is the ZeroKMS client id the login provisioned, if stored. + ClientID string + // DeviceInstanceID is the identity of the device that logged in, if + // stored. + DeviceInstanceID string +} + +// Usable reports whether the token is still usable at now: before its real +// expiry. The Rust crate's is_usable, not its is_expired, which subtracts a +// refresh-ahead margin that belongs to refreshing. +func (t Token) Usable(now time.Time) bool { return now.Before(t.ExpiresAt) } + +// Token reads auth.json in this store (a workspace store; the root holds +// none). The transport copy of the token is wiped once it is read out. +func (s *ProfileStore) Token(ctx context.Context) (Token, error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.token }) + if err != nil { + return Token{}, err + } + defer guest.Wipe(out) + fields, err := object(out) + if err != nil { + return Token{}, err + } + var t Token + if t.AccessToken, err = fields.text("access_token"); err != nil { + return Token{}, err + } + if t.TokenType, err = fields.text("token_type"); err != nil { + return Token{}, err + } + expiresAt, err := fields.uint64Field("expires_at") + if err != nil { + return Token{}, err + } + if expiresAt > math.MaxInt64 { + return Token{}, fmt.Errorf("%w: expires_at %d is out of range", ErrInternal, expiresAt) + } + t.ExpiresAt = time.Unix(int64(expiresAt), 0) + if t.Region, err = fields.optionalText("region"); err != nil { + return Token{}, err + } + if t.ClientID, err = fields.optionalText("client_id"); err != nil { + return Token{}, err + } + if t.DeviceInstanceID, err = fields.optionalText("device_instance_id"); err != nil { + return Token{}, err + } + return t, nil +} diff --git a/languages/golang/stackauth/transport.go b/languages/golang/stackauth/transport.go new file mode 100644 index 000000000..4f39fa911 --- /dev/null +++ b/languages/golang/stackauth/transport.go @@ -0,0 +1,265 @@ +package stackauth + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "net/http" + "sort" + "strings" + "sync" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +// OIDCProvider supplies the current identity-provider JWT. The Rust +// federation strategy asks only when its cached CTS token needs renewal. +// +// Token runs inside the guest call that needs it, while the ProfileStore +// that owns the strategy is locked. It must not call back into that +// ProfileStore or any strategy of it (reading a stored token, building +// another strategy): the call would wait on the same lock and deadlock +// rather than fail. OAuth2TokenSource does not touch the store, so it is +// safe to use from here. +type OIDCProvider interface { + Token(context.Context) (string, error) +} + +// OIDCProviderFunc adapts a function to OIDCProvider. +type OIDCProviderFunc func(context.Context) (string, error) + +func (f OIDCProviderFunc) Token(ctx context.Context) (string, error) { return f(ctx) } + +const maxAuthResponseBytes = 16 << 20 + +// authHTTPStatus records the status of the last HTTP response the transport +// received during one guest call. Only a status code crosses the guest ABI, +// so without it a refused exchange (the edge in front of CTS answering 403) +// reaches the caller as a bare ErrAuthTransport. It lives on the call's +// context, which wazero hands to the host import, so concurrent calls on +// different profiles never see each other's status. +type authHTTPStatus struct{ code int } + +type authHTTPStatusKey struct{} + +func withAuthHTTPStatus(ctx context.Context) (context.Context, *authHTTPStatus) { + status := &authHTTPStatus{} + return context.WithValue(ctx, authHTTPStatusKey{}, status), status +} + +// wrap names the HTTP status of a refused exchange on an ErrAuthTransport +// ("cipherstash: auth transport failed: HTTP 403"). The body is never +// included: it may be an HTML error page, or echo a credential. +func (s *authHTTPStatus) wrap(err error) error { + if err == nil || !errors.Is(err, ErrAuthTransport) || s.code == 0 || (s.code >= 200 && s.code < 300) { + return err + } + return fmt.Errorf("%w: HTTP %d", err, s.code) +} + +type authTransport struct { + rt http.RoundTripper + mu sync.Mutex + providers map[uint32]OIDCProvider + nextProvider uint32 +} + +func newAuthTransport(rt http.RoundTripper) *authTransport { + if rt == nil { + rt = http.DefaultTransport + } + return &authTransport{rt: rt, providers: make(map[uint32]OIDCProvider)} +} + +func (t *authTransport) register(provider OIDCProvider) uint32 { + t.mu.Lock() + defer t.mu.Unlock() + t.nextProvider++ + if t.nextProvider == 0 { + t.nextProvider++ + } + id := t.nextProvider + t.providers[id] = provider + return id +} + +func (t *authTransport) unregister(id uint32) { + t.mu.Lock() + delete(t.providers, id) + t.mu.Unlock() +} + +func (t *authTransport) instantiate(ctx context.Context, r wazero.Runtime) error { + _, err := r.NewHostModuleBuilder("cipherstash_transport"). + NewFunctionBuilder().WithFunc(t.send).Export("transport_send"). + NewFunctionBuilder().WithFunc(t.oidcTokenGet).Export("oidc_token_get"). + Instantiate(ctx) + return err +} + +// send is the same host import contract used by stackencrypt: four input +// buffers and two output slots, with a negative status on transport failure. +func (t *authTransport) send(ctx context.Context, m api.Module, + methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen uint32, + respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut uint32, +) int32 { + mem := m.Memory() + method, ok1 := mem.Read(methodPtr, methodLen) + url, ok2 := mem.Read(urlPtr, urlLen) + headers, ok3 := mem.Read(headersPtr, headersLen) + body, ok4 := mem.Read(bodyPtr, bodyLen) + if !ok1 || !ok2 || !ok3 || !ok4 { + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte("request buffer out of range")) + } + requestBody := newAuthRequestBody(body) + req, err := http.NewRequestWithContext(ctx, string(method), string(url), requestBody) + if err != nil { + _ = requestBody.Close() + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte(err.Error())) + } + req.ContentLength = int64(len(body)) + req.Header = parseAuthHeaders(headers) + resp, err := t.rt.RoundTrip(req) + if err != nil { + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte(err.Error())) + } + defer resp.Body.Close() + // A caller's RoundTripper can return any int; the guest gets an i32. + if resp.StatusCode < 100 || resp.StatusCode > 999 { + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, fmt.Appendf(nil, "invalid HTTP status %d", resp.StatusCode)) + } + if status, ok := ctx.Value(authHTTPStatusKey{}).(*authHTTPStatus); ok { + status.code = resp.StatusCode + } + if resp.ContentLength > maxAuthResponseBytes { + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte("auth response exceeds limit")) + } + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxAuthResponseBytes+1)) + if err != nil { + guest.Wipe(responseBody) + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte(err.Error())) + } + defer guest.Wipe(responseBody) + if len(responseBody) > maxAuthResponseBytes { + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte("auth response exceeds limit")) + } + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, int32(resp.StatusCode), encodeAuthHeaders(resp.Header), responseBody) //nolint:gosec // range-checked above +} + +func (t *authTransport) placeResponse(ctx context.Context, m api.Module, + hp, hl, bp, bl uint32, status int32, headers, body []byte, +) int32 { + if !placeAuth(ctx, m, hp, hl, headers) || !placeAuth(ctx, m, bp, bl, body) { + return -1 + } + return status +} + +func (t *authTransport) oidcTokenGet(ctx context.Context, m api.Module, provider, ptrOut, lenOut uint32) int32 { + t.mu.Lock() + source := t.providers[provider] + t.mu.Unlock() + if source == nil { + return 1 + } + token, err := source.Token(ctx) + if err != nil || token == "" || strings.ContainsAny(token, "\r\n\x00") { + return 1 + } + bytes := []byte(token) + defer guest.Wipe(bytes) + if !placeAuth(ctx, m, ptrOut, lenOut, bytes) { + return 1 + } + return 0 +} + +// The only guest re-entry allowed during a host import is its allocator. +func placeAuth(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data []byte) bool { + alloc := m.ExportedFunction("se_alloc") + if alloc == nil { + return false + } + if uint64(len(data)) > math.MaxUint32 { + return false + } + res, err := alloc.Call(ctx, uint64(len(data))) + if err != nil || len(res) == 0 || res[0] == 0 { + return false + } + ptr := api.DecodeU32(res[0]) + mem := m.Memory() + return (len(data) == 0 || mem.Write(ptr, data)) && + mem.WriteUint32Le(ptrOut, ptr) && mem.WriteUint32Le(lenOut, uint32(len(data))) //nolint:gosec // bounded above +} + +func parseAuthHeaders(buf []byte) http.Header { + h := make(http.Header) + for _, line := range strings.Split(string(buf), "\n") { + name, value, ok := strings.Cut(line, ":") + if ok { + h.Add(strings.TrimSpace(name), strings.TrimSpace(value)) + } + } + return h +} + +func encodeAuthHeaders(h http.Header) []byte { + names := make([]string, 0, len(h)) + for name := range h { + names = append(names, name) + } + sort.Strings(names) + var out strings.Builder + for _, name := range names { + for _, value := range h[name] { + if out.Len() > 0 { + out.WriteByte('\n') + } + fmt.Fprintf(&out, "%s: %s", name, value) + } + } + return []byte(out.String()) +} + +// The host owns its request-body copy until the RoundTripper closes it. +// A transport may read after RoundTrip returns, so wiping on return races. +type authRequestBody struct { + mu sync.Mutex + buf []byte + off int + closed bool +} + +func newAuthRequestBody(src []byte) *authRequestBody { + buf := append([]byte(nil), src...) + return &authRequestBody{buf: buf} +} + +func (b *authRequestBody) Read(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return 0, errors.New("auth request body read after close") + } + if b.off == len(b.buf) { + return 0, io.EOF + } + n := copy(p, b.buf[b.off:]) + b.off += n + return n, nil +} + +func (b *authRequestBody) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + if !b.closed { + guest.Wipe(b.buf) + b.closed = true + } + return nil +} diff --git a/languages/golang/stackauth/wasm/README.md b/languages/golang/stackauth/wasm/README.md new file mode 100644 index 000000000..cfdbc2cd8 --- /dev/null +++ b/languages/golang/stackauth/wasm/README.md @@ -0,0 +1,7 @@ +# Guest module + +`stack_auth_guest.wasm` is a build artefact of the Rust crate in +`../guest`, copied here by `mise run wasm:auth-guest:build`. It is not +committed; the Go package embeds this directory and reports +`ErrGuestNotBuilt` from `Open` when the module is absent, and its tests +skip. diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md new file mode 100644 index 000000000..bef131e87 --- /dev/null +++ b/languages/golang/stackencrypt/README.md @@ -0,0 +1,344 @@ +# stack-encrypt for Go + +Client-side encryption of values under per-value ZeroKMS data keys, and the +derivation of searchable index terms from the same values, for Go. The +`stack-encrypt` Rust crate is compiled to a WASI module and embedded in +this package; Go calls it through wazero, a pure-Go WebAssembly runtime, +so there is no cgo and no separate Go port of the cryptography. + +The package reference is on [pkg.go.dev]; this README covers connecting, +what happens to key material, and the errors. + +[pkg.go.dev]: https://pkg.go.dev/github.com/cipherstash/stack/languages/golang/stackencrypt + +## Install + +```sh +go get github.com/cipherstash/stack/languages/golang/stackencrypt +``` + +Go 1.25 or later. + +## Connect + +A `Client` is one ZeroKMS client: its client key, its default keyset, and +the keysets it has loaded since. Make one per process and share it; it is +safe for concurrent use. + +```go +import ( + "context" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +func run(ctx context.Context) error { + client, err := stackencrypt.NewClient(ctx) + if err != nil { + return err // stackencrypt.ErrNoCredentials: nothing configured + } + defer client.Close() + + // ... + return nil +} +``` + +`ctx` is Go's standard `context.Context`, and it means what it always +means: the deadline and cancellation for the work this call does. +`NewClient` makes one ZeroKMS round trip, to load the client's default +keyset, and `ctx` bounds that request. It has nothing to do with an +*encryption* context, which is the value a field is sealed under; that is +`stackencrypt.Context`. Every method that can reach ZeroKMS takes a +`context.Context` first, for the same reason. + +Everything else is a functional option, and each has a default: + +```go +client, err := stackencrypt.NewClient(ctx, + stackencrypt.WithCredentials(stackencrypt.OIDCFederation(crn, provider)), + stackencrypt.WithTransport(rt), + stackencrypt.WithKeysetCacheSize(4096), + stackencrypt.WithRequireLockedMemory(), +) +``` + +| Option | Default | +|---|---| +| `WithCredentials(c)` | `AutoCredentials()`: see below. | +| `WithTransport(rt)` | `http.DefaultTransport`. Used for ZeroKMS, and for token requests when the credentials make them. | +| `WithKeysetCacheSize(n)` | 1024 keysets beyond the default one. | +| `WithRequireLockedMemory()` | Off: memory that cannot be locked is reported, not refused. See below. | +| `WithGuest(wasm)` | The embedded guest module. | + +If an option is given twice, the later one wins. + +### Credentials + +With no `WithCredentials`, `NewClient` finds its credentials the way the +Rust client does, with `AutoCredentials`: the environment first, then the +developer profile that `stash auth login` writes. On a developer machine, logging in is enough. +In CI or a deployment, the environment supplies them. The first two rows +are what a deployment with no profile needs; the rest override what would +otherwise be resolved: + +| Variable | Role | +|---|---| +| `CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN` | An access key, exchanged for a token. Without it, the current workspace's stored session is used, and refreshed as it expires. | +| `CS_CLIENT_ID`, `CS_CLIENT_KEY` | The client key, used when both are set. Without them, the current workspace's `secretkey.json` is used. | +| `CS_ZEROKMS_HOST` (or `CS_VITUR_HOST`) | Pins the ZeroKMS endpoint. Otherwise it comes from the token. Read whatever the credentials. | +| `CS_CTS_HOST` | Overrides the authentication endpoint. | +| `CS_CONFIG_PATH` | The profile directory, instead of `~/.cipherstash`. | + +A variable that is set but empty or unusable is an error, not a reason to +look elsewhere. Nothing found is `ErrNoCredentials`, naming what to set; +when the profile would have been consulted, it also says why the profile +could not be opened, so an unreadable or mistyped `CS_CONFIG_PATH` is not +reported as "not logged in". + +The ZeroKMS endpoint comes from the token's services claim; there is no +option to pin it. `CS_ZEROKMS_HOST` (or the legacy `CS_VITUR_HOST`) +overrides it whatever the credentials, `NewCredentials` included, as the +Rust client reads them, so a value exported there for another tool is +worth checking. + +Resolution happens host-side, in Go. The profile and the token strategies +run in `stackauth`'s credential guest; the crypto guest that holds the +keys is still given no environment and no filesystem. The credential guest +lives as long as the client, and `Close` releases it. + +To supply the credentials yourself, pass `NewCredentials` with a client +id, a client key and a `stackauth` strategy for the token: + +```go +store, err := stackauth.OpenWithoutProfile(ctx) // or stackauth.Resolve(ctx) for the profile +if err != nil { + return err +} +defer store.Close() +strategy, err := store.AccessKey(ctx, crn, accessKey) // or DeviceSession, OIDC, Auto +if err != nil { + return err +} +defer strategy.Close() +client, err := stackencrypt.NewClient(ctx, + stackencrypt.WithCredentials(stackencrypt.NewCredentials(clientID, clientKey, strategy)), +) +if err != nil { + return err +} +defer client.Close() +``` + +The strategy is asked for the bearer token on every request, and mints or +refreshes it as it needs to. The store and the strategy stay yours: the +client never closes them, so keep them open until `client.Close` has +returned, as the deferred calls above do. A nil strategy is refused. + +Tokens come only from `stackauth` strategies; there is no way to hand the +client a raw bearer token. A raw token cannot be refreshed when it expires, +and a source outside the strategies would bypass the cross-process lock a +device-session refresh holds with the `stash` CLI. + +To authenticate through your own identity provider, pass `OIDCFederation` +with the workspace CRN and a provider of the IdP's tokens. CTS exchanges +the IdP token for a CipherStash one, and the provider is asked again only +when that token needs replacing. `stackauth.OAuth2TokenSource` adapts a +`golang.org/x/oauth2` source. The client key is found as `AutoCredentials` +finds it. `stackauth` strategy options follow the provider: +`OIDCFederation(crn, provider, stackauth.WithAuthBaseURL(cts))` pins the CTS +endpoint for these credentials, where `CS_CTS_HOST` would pin it for the +whole process. + +`AutoCredentials`, `NewCredentials` and `OIDCFederation` are the only kinds +of `Credentials`: the interface is sealed. + +`ClientKey` is an opaque type, not a string: it prints a redaction under +every verb, so logged credentials never show the key. `NewClientKey` takes +ownership of the slice it is given, and `NewClient` consumes the key — +whatever the outcome, even options it refuses, the key is empty afterwards +and that slice is zero. A key is for one client; build another for another +client. What the SDK cannot reach is what the key was built *from*: a +string read from the environment is Go's, immutable, and lives until +collected. Where an environment variable is the source, treat the process +environment as holding the key for the life of the process. + +### Key material in memory + +The client key enters the instance once, in `NewClient`: it is marshalled +into the config buffer, the `ClientKey` is wiped, the guest copies the key +into its own memory, and the buffer is wiped. From then on the client key, +every loaded index key and every data key in use live in +the wasm instance's memory, and the package owns that memory rather than +leaving it to the runtime's default. It is reserved once and never moves, +so growth never copies a key to somewhere it is not wiped; it is locked in +RAM (`mlock`, `VirtualLock`) so it is never written to swap; on Linux it is +excluded from core dumps (`MADV_DONTDUMP`); and it is wiped before it is +released, on every release path. None of that waits for `Close`. A process +killed by SIGKILL, the OOM killer, a panic on another goroutine or `os.Exit` +runs no deferred call, and the kernel zeroes its pages before anyone else +sees them; the lock and the dump exclusion close the two places a copy +could otherwise outlive the process. + +The lock is best effort. `RLIMIT_MEMLOCK` defaults to 64 KiB on many Linux +hosts and the instance is larger, so the lock is often refused, and the +client then runs with memory the kernel may swap out, which is all that is +lost, and nothing on a host without swap. `client.MemoryLocked()` reports +the outcome and `client.MemoryLockError()` names the limit to raise +(`ulimit -l`, a systemd `LimitMEMLOCK=`, a pod `securityContext`) and the +size the instance holds. For a deployment that would rather not start than +run unlocked, pass `WithRequireLockedMemory()` and `NewClient` fails with +`ErrMemoryLock`. That policy holds for the life of the client: memory the +instance later grows into must lock too, or the call that needed it fails +with `ErrMemoryLock`, so grant a limit with room to grow. A `Client` prints +its memory state with `%v` and logs it as a `slog` group, so a startup log +shows it. + +The report and the policy cover the credential guest too, which holds the +token strategy and which the client key may have passed through. +`AutoCredentials` and `OIDCFederation` open it under the client's policy. +With `NewCredentials` it is the `stackauth` store you opened: under +`WithRequireLockedMemory()`, `NewClient` fails with `ErrMemoryLock` if that +store's memory is unlocked, but the store's own policy decides its later +growth. Open it with `stackauth.RequireLockedMemory()` as well to keep it +locked for the life of the client. + +Production checklist: assert `MemoryLocked()` at startup, or pass +`WithRequireLockedMemory()`, and with `NewCredentials` open the store with +`stackauth.RequireLockedMemory()`. Handling `SIGTERM` for a graceful shutdown is +ordinary Go practice and worth doing for your own reasons; the SDK does not +depend on it and installs no signal handler of its own. + +`Close` runs the guest's own shutdown, wiping every key in place before the +instance is released, and takes no context because it does no I/O. A +`Client` that becomes unreachable without `Close` is released by a runtime +cleanup, which covers the forgot-to-close case in a running process and +nothing at exit. + +## Plans from a policy + +A record plan says which fields to encrypt, under which context, with which +index terms. `stash` tags or `NewPlan` spell it out by hand. The `plan` +subpackage derives it from what the schema already says about each field +(its facts, such as Fideslang `data_categories`), through a policy written +in Go: + +```go +import "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" + +type Individual struct { + ID int64 + Email string + MedicareNo string +} + +// Facts come from a Source, such as the protobuf one planned in CIP-4088. +// Any function returning facts is one. +var source = plan.SourceFunc(func(msg any) ([]plan.Fact, error) { + return []plan.Fact{ + {Field: "id", GoField: "ID"}, + {Field: "email", GoField: "Email", Annotations: []plan.Annotation{ + {Key: "fides.data_categories", Values: []string{"user.contact.email"}}}}, + {Field: "medicare_no", GoField: "MedicareNo", Annotations: []plan.Annotation{ + {Key: "fides.data_categories", Values: []string{"user.government_id"}}}}, + }, nil +}) + +var category = plan.Key("fides.data_categories") + +var Base = plan.FirstOf( + plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(stackencrypt.Equality))), + plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(stackencrypt.Equality, stackencrypt.Match))), + plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), +) + +var Individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), + plan.FirstOf( + plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), + plan.Column("medicare_number")), + ).OrElse(Base), +) + +// At startup: panics if a classified field is decided by no rule, or the +// plan names a field the struct does not have. +var individuals = plan.MustPlanFor(source, Individuals) + +records, err := cipher.EncryptRecords(ctx, rows, stackencrypt.WithPlan(individuals)) +``` + +A policy fails closed: a field with facts that no rule decides is an error +when the plan is built, naming the field and its facts. There is no default; +write a catch-all, `Plaintext()` included, in the policy. Fields with no +facts are left out and stored as they are. A message the policy encrypts +nothing of has no plan: `PlanFor` reports `ErrNothingEncrypted`, and its +records are stored without one. + +An EQL target's context is its column identity, `"<table>/<column>"`. The +table is required per message, never derived from its name. A field is +stored in the column named by its schema name (its `Fact.Field`, such as +`medicare_no`: the spelling the Rust derive and the database column share) +unless a rule names another with `plan.Column`, and that column is also its +identity unless the rule pins one with `plan.Identity`. `plan.Field` matches on that same schema name. + +The identity is bound into every stored ciphertext, its data key and its +index terms, so once data is written it must never change. A field never +renamed in the database needs no `Identity`. After +`ALTER TABLE individuals RENAME COLUMN medicare_number TO medicare_num`, +new writes go to the new column under the old identity: + +```go +plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), + plan.Column("medicare_num"), plan.Identity("medicare_number")) +``` + +`plan.Custom` targets supply their own context: `plan.Column` names only +their record key, and `plan.Identity` is refused. The plan a +policy builds is a `Plan` like any other: the guest receives the same bytes +as for the equivalent hand-built plan. + +## Errors + +Errors are sentinel values, matched with `errors.Is`. The wasm guest +reports a status code and nothing else, so the vocabulary is deliberately +small and reveals nothing about plaintext or key material. + +| Error | Meaning | +|---|---| +| `ErrAuthentication` | A ciphertext failed to open: tampered, or presented under the wrong context or element derivation. | +| `ErrForbidden` | ZeroKMS refused the request. Also the production form of a wrong-context open, because every data key is bound to its context. | +| `ErrUnauthorized` | ZeroKMS rejected the bearer token: invalid, expired, or for another workspace. | +| `ErrNotFound` | Unknown keyset name or id, or a missing data key. | +| `ErrForeignKeyset` | A keyset-bound `Cipher` was given another keyset's ciphertext. Open it through the `Client`. | +| `ErrEncoding` | Malformed input: a value, ciphertext, plan, context or config refused before any cryptography. | +| `ErrTerm` | A term could not be derived, for example match text that yields no tokens. | +| `ErrTransport` | ZeroKMS could not be reached, or the token strategy failed. The strategy's own error is wrapped in it, so `errors.Is` finds that too (a refused refresh is `stackauth.ErrInvalidGrant`). | +| `ErrKMS` | Any other ZeroKMS failure. | +| `ErrConflict` | ZeroKMS reported a resource conflict. | +| `ErrState` | The client has been closed: by `Close`, by a call its context interrupted, or by a guest trap. | +| `ErrMemoryLock` | The instance's memory could not be locked in RAM. Returned by `NewClient` under `WithRequireLockedMemory()`, and by a call whose growth could not be locked; otherwise reported by `MemoryLockError`. | +| `ErrNoCredentials` | `NewClient` found no token strategy or no client key, in the environment or the profile. The message names what to set. | +| `ErrCredentialsConsumed` | `NewCredentials` given to a second `NewClient`: the first consumed its key. Build new credentials, with a new key, for another client. | +| `ErrInternal` | An unexpected failure inside the guest. | + +## How it works under the hood + +- **One implementation.** The `stack-encrypt` Rust crate is compiled to a + WASI module and embedded in the package. There is no separate Go port of + the cryptography, so ciphertexts and terms are byte-identical to the Rust + crate's and interchange with every other binding. +- **Key material stays in the guest.** The client key crosses into wasm + memory once at `NewClient`. Data keys are retrieved from ZeroKMS into + guest memory and never surface in Go. That memory is the package's own: + reserved once so it never moves, locked and excluded from core dumps + where the platform allows, wiped before release. `Close` runs the guest's + own shutdown as well, so every key is wiped in place before the instance + is released. +- **Two host imports.** The guest imports exactly one HTTP send, served by + your `http.RoundTripper`, and one bearer-token fetch, served by the + credentials' `stackauth` strategy. What crosses the boundary per ZeroKMS call is what would + cross TLS anyway. The guest sees no environment and no filesystem. +- **Real randomness.** The guest draws IVs and nonces from the process + CSPRNG. wazero's default random source is deterministic, so the package + configures every instance with `crypto/rand` explicitly. +- **Concurrency.** A wasm instance is single-threaded, so calls on one + `Client` are serialised internally. The `Client` is safe to share. diff --git a/languages/golang/stackencrypt/cipher.go b/languages/golang/stackencrypt/cipher.go new file mode 100644 index 000000000..5820a6aa8 --- /dev/null +++ b/languages/golang/stackencrypt/cipher.go @@ -0,0 +1,161 @@ +package stackencrypt + +import ( + "context" + "fmt" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" +) + +// Cipher is a [Client] bound to one keyset: the Go form of the Rust +// crate's KeysetCipher. It seals values, derives terms and encrypts records +// under that keyset, and opens only that keyset's ciphertexts — a leaf +// sealed under another keyset is refused as [ErrForeignKeyset] before any +// key is retrieved. To open ciphertexts from any keyset, use the Client's +// decrypt methods. +// +// A Cipher holds no guest state: the keyset is selected on every call, and +// loaded by the guest on first use. +type Cipher struct { + client *Client + keyset KeysetSelector +} + +// Client is the client this cipher belongs to. +func (cph *Cipher) Client() *Client { return cph.client } + +// Keyset is the selector this cipher is bound to. +func (cph *Cipher) Keyset() KeysetSelector { return cph.keyset } + +// KeysetID resolves the cipher's keyset to its id: Rust's +// KeysetCipher::keyset_id, and in Go the one explicit resolution point, +// since a Cipher is made without a request. The first use of a name or id +// on the client is one ZeroKMS round trip, later uses come from the +// guest's cache; the default keyset never makes a request. Use it at boot +// to validate a tenant's keyset and learn its id. +func (cph *Cipher) KeysetID(ctx context.Context) (KeysetID, error) { + return cph.client.resolveKeyset(ctx, cph.keyset) +} + +// Encrypt seals v under this keyset. v is encoded through the vcvalue +// model: builtins, slices, maps and structs by reflection, a type +// implementing vcffi.Encryptable by its own encoding, vcvalue.Plain marking +// a passthrough. aad is authenticated but not encrypted, and may be empty; +// the same aad must be presented to Decrypt. The leaves of v seal from +// batched ZeroKMS key requests: one per 500 keyed leaves, so one request +// for any ordinary value. +// +// The ciphertext comes back as ordinary Go values mirroring the +// plaintext's structure: Sealed leaves (and the SealedNone / SealedEmptySeq +// / SealedEmptyMap markers) where fields were encrypted, vcvalue.Plain +// where they passed through, map[string]any for records, []any for +// sequences. +func (cph *Cipher) Encrypt(ctx context.Context, v any, aad []byte) (any, error) { + return cph.encryptValue(ctx, v, aad, false) +} + +// EncryptElement seals v as a sequence element of the collection identified +// by aad — byte-identical to what Encrypt of a whole slice binds per +// element — so a single row inserted this way interchanges with rows +// written by encrypting a slice under the same aad. +func (cph *Cipher) EncryptElement(ctx context.Context, v any, aad []byte) (any, error) { + return cph.encryptValue(ctx, v, aad, true) +} + +// Decrypt opens a ciphertext sealed under this keyset. ct is the shape +// Encrypt returns (any subset of a record's entries decrypts); the +// plaintext is returned in vcvalue's decode shape: Go natives, +// vcvalue.Object for records, vcvalue.Plain for passthrough fields. A leaf +// from another keyset is ErrForeignKeyset. +func (cph *Cipher) Decrypt(ctx context.Context, ct any, aad []byte) (any, error) { + return cph.client.decryptValue(ctx, cph.keyset, ct, aad, false) +} + +// DecryptElement opens a ciphertext sealed as a sequence element — a row +// of a collection encrypted from a slice, or by EncryptElement — under the +// same aad. Elements are authenticated against a derivation of the +// collection's aad, so Decrypt cannot open a lone row. +func (cph *Cipher) DecryptElement(ctx context.Context, ct any, aad []byte) (any, error) { + return cph.client.decryptValue(ctx, cph.keyset, ct, aad, true) +} + +// Term derives one index term for value under context: the probe that +// compares against a term stored by EncryptRecords for a field sealed under +// the same keyset and the same context. value is a scalar: an integer, +// string or byte slice for Equality (floats and booleans have no equality +// encoding); a string for Match; any scalar for Ore and Ope. The result is +// one of EqualityTerm, MatchTerm, OreTerm or OpeTerm. +// +// Term takes a context and returns an error because it may be a ZeroKMS +// round trip: term derivation is asynchronous in the Rust crate, and a +// ZeroKMS backend that derives terms server-side settles the same way. +func (cph *Cipher) Term(ctx context.Context, value any, context Context, kind TermKind) (any, error) { + if context.node == nil { + return nil, fmt.Errorf("stackencrypt: term context is empty") + } + encodedValue, err := vcffi.Marshal(value) + if err != nil { + return nil, err + } + // The probe value is plaintext: its transport copy is wiped once it is + // in the guest, as is the context it binds. + defer wipe(encodedValue) + encodedContext, err := vcffi.Marshal(context.value()) + if err != nil { + return nil, err + } + defer wipe(encodedContext) + opts, err := vcffi.Marshal(options(cph.keyset)) + if err != nil { + return nil, err + } + out, err := cph.client.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.term, buf(encodedValue), buf(encodedContext), scalar(uint64(kind)), buf(opts)) + }) + if err != nil { + return nil, err + } + return typedTerm(kind, out), nil +} + +func typedTerm(kind TermKind, bytes []byte) any { + switch kind { + case Equality: + return EqualityTerm(bytes) + case Match: + return MatchTerm(bytes) + case Ore: + return OreTerm(bytes) + case Ope: + return OpeTerm(bytes) + default: + return bytes + } +} + +func (cph *Cipher) encryptValue(ctx context.Context, v any, aad []byte, element bool) (any, error) { + encoded, err := vcffi.Marshal(v) + if err != nil { + return nil, err + } + // The transport copy of the plaintext is wiped once it is in the guest. + defer wipe(encoded) + opts, err := vcffi.Marshal(options(cph.keyset)) + if err != nil { + return nil, err + } + out, err := cph.client.call(ctx, func(inst *instance) ([]byte, error) { + fn := inst.encrypt + if element { + fn = inst.encryptElement + } + return inst.call(ctx, fn, buf(encoded), buf(aad), buf(opts)) + }) + if err != nil { + return nil, err + } + // Passthrough (vcvalue.Plain) fields come back in the clear; the + // serialized copy is wiped once decoded. + defer wipe(out) + return unmarshalCipherText(out) +} diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go new file mode 100644 index 000000000..8482cb573 --- /dev/null +++ b/languages/golang/stackencrypt/client.go @@ -0,0 +1,488 @@ +package stackencrypt + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "runtime" + "strconv" + "sync" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// Client is one wasm instance holding one ZeroKMS client: its key, its +// default keyset, and the keysets it has loaded since. It is safe for +// concurrent use; calls are serialised internally, because a wasm instance +// is single-threaded. Close it when done, as with any resource. +// +// Its key material lives in the guest's linear memory, which this package +// supplies: reserved once so it never moves, locked in RAM and excluded +// from core dumps where the platform allows, and wiped before it is +// released. None of that depends on Close running — no exit path a process +// can take (a signal with no handler, SIGKILL, the OOM killer, a panic on +// another goroutine, os.Exit) runs deferred calls, and none of them is +// where the protection lives. [Client.MemoryLocked] reports whether the +// lock was granted. +type Client struct { + mu sync.Mutex + inst *instance + transport *transport + // closed refuses further calls: either Close ran, or an interrupted + // call took the module down under us (see Client.call). released is + // the runtime's own state, tracked apart from it because those two + // things come apart: an interrupted call closes the module — and so + // the client — while the runtime and the host modules beside it are + // still allocated. Close is what frees those, so it must do its work + // even on a client that is already closed. + closed bool + released bool + def KeysetID + // releaseCredentials is the resolved credentials' Close, run once by + // Close. Nil when they hold nothing open. + releaseCredentials func() error + // credentialsLockErr is the resolved credentials' MemoryLockError: the + // memory the key passed through before it reached this guest, asked + // live and folded into MemoryLocked so the report covers every guest + // that held it, as it is now. Nil when the credentials report nothing. + credentialsLockErr func() error + // cleanup releases the instance if the Client becomes unreachable + // without Close: the forgot-to-close case in a running process. It + // does nothing at process exit, and is not meant to. + cleanup runtime.Cleanup +} + +// NewClient resolves the credentials, instantiates the guest, loads the +// client key into it, and loads the default keyset — one ZeroKMS round trip, +// which is where credentials that resolve but do not work fail: a token that +// cannot be minted or is refused fails here, not at first use. The returned +// client is ready to seal. +// +// With no options it is a working client: the credentials are +// [AutoCredentials], and every other setting has a default. Each +// [ClientOption] changes one. +func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) { + var cfg clientOptions + for _, opt := range opts { + opt(&cfg) + } + rt := cfg.transport + if rt == nil { + rt = http.DefaultTransport + } + creds := cfg.credentials + if creds == nil { + creds = AutoCredentials() + } + // Credentials a later WithCredentials replaced are never resolved, but + // an explicit key in them is still the client's to consume — unless the + // replacement is the same credentials passed again, whose key is the + // one this client resolves. + for _, c := range cfg.superseded { + if !sameExplicit(c, creds) { + consumeUnresolved(c) + } + } + // The host-side checks come first: they resolve nothing, and under + // AutoCredentials resolving means instantiating the credential guest + // and reading the profile, which a config refused here should not pay + // for. A refused config still consumes an explicit key, as + // WithCredentials promises; any other Credentials has not been asked + // yet, so holds nothing of this client's. + zerokmsURL, err := zerokmsEndpoint(cfg.zerokmsURL) + if err == nil && cfg.keysetCacheSize < 0 { + err = fmt.Errorf("%w: WithKeysetCacheSize must not be negative", ErrEncoding) + } + if explicit, ok := creds.(*explicitCredentials); ok && err == nil && explicit.token == nil { + // Knowable from the credentials as they were built: the one place + // a missing token source is decided. + err = fmt.Errorf("%w: NewCredentials needs a stackauth strategy for the token", ErrEncoding) + } + wasm := cfg.guest + if err == nil && wasm == nil { + wasm, err = embeddedGuest() + } + if err != nil { + consumeUnresolved(creds) + return nil, err + } + resolved, err := creds.resolve(ctx, resolveOptions{Transport: rt, RequireLockedMemory: cfg.requireLockedMemory}) + if err != nil { + // A resolve that fails may still hand back what it built. The key + // is consumed and what Close holds is released, as on every other + // path: nothing of the client's outlives a failed NewClient. + if resolved != nil { + resolved.ClientKey.Wipe() + if resolved.Close != nil { + _ = resolved.Close() + } + } + return nil, err + } + if resolved == nil { + return nil, fmt.Errorf("%w: the credentials resolved to nothing", ErrEncoding) + } + // Nil-safe, and a no-op after the wipe on the accepted path. + defer resolved.ClientKey.Wipe() + // The credentials are the client's to release once it exists — its + // Close does, on the paths below as at the end of its life — and until + // then NewClient's. + owned := false + defer func() { + if err != nil && !owned && resolved.Close != nil { + _ = resolved.Close() + } + }() + if cfg.requireLockedMemory && resolved.MemoryLockError != nil { + // The credentials' own guest held the key, and holds the token + // strategy: under the strict policy its memory must be locked too. + // AutoCredentials and OIDCFederation open it strict and cannot get + // here unlocked; NewCredentials' store is the caller's, opened + // however the caller chose. + if lockErr := resolved.MemoryLockError(); lockErr != nil { + return nil, fmt.Errorf("stackencrypt: the credentials' memory: %w", lockErr) + } + } + encoded, err := encodeConfig(initConfig{ + clientID: resolved.ClientID, + clientKey: resolved.ClientKey, + zerokmsURL: zerokmsURL, + keysetCacheSize: cfg.keysetCacheSize, + }) + if err != nil { + return nil, err + } + defer wipe(encoded) + // The key is consumed: it is in the config buffer now, and the buffer + // is wiped once the guest has it. Wiping the key here rather than after + // the init call keeps the exposure to one copy from this point on, + // whatever the init's outcome. + resolved.ClientKey.Wipe() + + t := &transport{rt: rt, token: resolved.Token} + inst, err := newInstance(ctx, wasm, t, guest.PolicyFor(cfg.requireLockedMemory)) + if err != nil { + return nil, err + } + c := newClient(inst, t) + // From here the credentials are the client's: every exit below goes + // through its Close, which releases them once, and so does the + // client's own Close later. + c.releaseCredentials = resolved.Close + c.credentialsLockErr = resolved.MemoryLockError + owned = true + out, err := c.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.cipherInit, buf(encoded)) + }) + if err != nil { + _ = c.Close() + return nil, fmt.Errorf("stackencrypt: cipher init: %w", err) + } + if len(out) != len(KeysetID{}) { + _ = c.Close() + return nil, fmt.Errorf("%w: cipher init returned %d bytes for the keyset id", ErrInternal, len(out)) + } + copy(c.def[:], out) + return c, nil +} + +// consumeUnresolved is what NewClient owes a Credentials it refuses a +// config without asking: explicit credentials hold their key from +// construction, so it is wiped rather than handed back live, and they are +// marked consumed, so a retry with a corrected config is refused with +// ErrCredentialsConsumed rather than told the wiped values are missing. +// Any other implementation has not been asked, and holds nothing of this +// client's. +// sameExplicit reports whether a and b are the same NewCredentials value. +// It compares the concrete pointers: comparing the interfaces would panic +// on an implementation that is not comparable, such as a func type. +func sameExplicit(a, b Credentials) bool { + x, ok := a.(*explicitCredentials) + y, isExplicit := b.(*explicitCredentials) + return ok && isExplicit && x == y +} + +func consumeUnresolved(creds Credentials) { + if explicit, ok := creds.(*explicitCredentials); ok { + explicit.consumed.Store(true) + explicit.key.Wipe() + } +} + +// newClient wraps an instance and arms its cleanup. The cleanup takes the +// instance, not the client: a cleanup whose argument reaches its object +// keeps that object alive forever. +func newClient(inst *instance, t *transport) *Client { + c := &Client{inst: inst, transport: t} + c.cleanup = runtime.AddCleanup(c, func(inst *instance) { _ = inst.release() }, inst) + return c +} + +// MemoryLocked reports whether the memory the client's key material lives +// in — this guest's, where the client key and every loaded index key are, +// and whatever the credentials held it in on the way (stackauth's +// credential guest, for [AutoCredentials]) — is locked in RAM and, on +// Linux, excluded from core dumps. False means a lock was refused (on +// Linux, most often RLIMIT_MEMLOCK, which defaults to 64 KiB on many +// hosts) or is not available on this platform, and the client is working +// on with memory the kernel may swap out. Nothing else changes. A +// production checklist should assert this, or set +// [WithRequireLockedMemory] and let NewClient refuse. +// [Client.MemoryLockError] says why. +func (c *Client) MemoryLocked() bool { return c.MemoryLockError() == nil } + +// MemoryLockError is why MemoryLocked is false: an error wrapping +// ErrMemoryLock that names what was refused and the limit that refused it, +// for this guest, the credentials' memory, or both. Nil while every one is +// locked. +func (c *Client) MemoryLockError() error { + var err error + if lerr := c.inst.mem.LockError(); lerr != nil { + err = guest.MemoryLockError(lerr) + } + if c.credentialsLockErr != nil { + err = errors.Join(err, c.credentialsLockErr()) + } + return err +} + +// memoryState is the memory's state for a log line: "locked", or the +// refusal. Nothing secret is printed. +func (c *Client) memoryState() string { + if err := c.MemoryLockError(); err != nil { + return fmt.Sprintf("unlocked: %v", err) + } + return "locked" +} + +// String implements fmt.Stringer so that a Client printed with %v or %s +// shows its memory state: "locked", or the refusal. Nothing secret is +// printed. The state is what an operator reading a startup log needs to +// see, and [Client.LogValue] gives it structured form. +func (c *Client) String() string { + return fmt.Sprintf("stackencrypt.Client{memory: %s}", c.memoryState()) +} + +// LogValue implements slog.LogValuer: a group with memory_locked and, when +// false, memory_lock_error. +func (c *Client) LogValue() slog.Value { + if err := c.MemoryLockError(); err != nil { + return slog.GroupValue(slog.Bool("memory_locked", false), slog.String("memory_lock_error", err.Error())) + } + return slog.GroupValue(slog.Bool("memory_locked", true)) +} + +// initConfig is what se_cipher_init takes: the resolved credentials' id +// and key, and the settings that reach the guest. +type initConfig struct { + clientID string + clientKey *ClientKey + zerokmsURL string + keysetCacheSize int +} + +// encodeConfig renders the se_cipher_init object. The result holds the +// client key; the caller wipes it, and the key it was read from. +func encodeConfig(cfg initConfig) ([]byte, error) { + if cfg.clientID == "" || cfg.clientKey.IsZero() { + return nil, errors.New("stackencrypt: the credentials' client id and client key are required") + } + // The key crosses as text: the guest's config parser takes the hex or + // base64 form as the CS_CLIENT_KEY variable and secretkey.json hold it. + // The string is a copy the marshaller reads once — and copies once more + // into its own scratch before appending — and the encoded buffer that + // results is what the caller wipes. A string cannot be wiped, and + // neither can the marshaller's copy; both live until the collector takes + // them: the copies of the key this package cannot zero, accepted for the + // length of NewClient. A marshaller that took bytes would remove both. + fields := vcvalue.Object{ + {Key: "client_id", Value: cfg.clientID}, + {Key: "client_key", Value: string(guest.KeyBytes(cfg.clientKey))}, + } + if cfg.zerokmsURL != "" { + fields = append(fields, vcvalue.Field{Key: "zerokms_url", Value: cfg.zerokmsURL}) + } + if cfg.keysetCacheSize > 0 { + fields = append(fields, vcvalue.Field{Key: "keyset_cache_size", Value: strconv.Itoa(cfg.keysetCacheSize)}) + } + return vcffi.Marshal(fields) +} + +// Close shuts the guest down — the client key and every loaded index key +// are wiped inside the instance — and releases the runtime and the +// guest's memory, which is wiped on the way out. Idempotent. Every call +// after it fails with ErrState. +// +// It takes no context because it does no I/O and must not be skippable: +// a deferred Close is ordinary resource hygiene, and the memory's +// protection (see [Client]) does not wait on it. +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + // Idempotency turns on the runtime, not on the client: a client an + // interrupted call already closed has never released its runtime. + if c.released { + return nil + } + c.released = true + c.closed = true + c.cleanup.Stop() + err := c.inst.release() + if c.releaseCredentials != nil { + err = errors.Join(err, c.releaseCredentials()) + } + return err +} + +// Keyset binds the client to one keyset, by name or by id: Rust's +// StackCipher::keyset. No request is made here — Go has no await, so the +// keyset is resolved by the guest on the cipher's first use (and cached), +// which makes a Cipher cheap to make per call, per tenant or per request. +// [Cipher.KeysetID] is the explicit resolution point. A nil selector is a +// programming error and panics; the default keyset is [Client.DefaultKeyset]. +func (c *Client) Keyset(sel KeysetSelector) *Cipher { + if sel == nil { + panic("stackencrypt: Client.Keyset(nil); the default keyset is Client.DefaultKeyset") + } + return &Cipher{client: c, keyset: sel} +} + +// DefaultKeyset binds the client to its default keyset — the one a ZeroKMS +// administrator set for this client, which is what naming no keyset +// resolves to: Rust's StackCipher::default_keyset. Loaded at NewClient, so +// using it never touches ZeroKMS. There is no way to redefine it from here; +// which keyset is the default is the server's to say. Any other keyset is +// [Client.Keyset]. +func (c *Client) DefaultKeyset() *Cipher { return &Cipher{client: c, keyset: defaultKeyset{}} } + +// resolveKeyset asks the guest for a selector's keyset id: the first use +// of a name or id on this client is one ZeroKMS round trip, later uses +// come from the guest's cache. The default keyset never makes a request. +func (c *Client) resolveKeyset(ctx context.Context, sel KeysetSelector) (KeysetID, error) { + encoded, err := vcffi.Marshal(sel.selector()) + if err != nil { + return KeysetID{}, err + } + out, err := c.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.keyset, buf(encoded)) + }) + if err != nil { + return KeysetID{}, err + } + var id KeysetID + if len(out) != len(id) { + return id, fmt.Errorf("%w: se_keyset returned %d bytes", ErrInternal, len(out)) + } + copy(id[:], out) + return id, nil +} + +// Decrypt opens a ciphertext produced by any keyset of this client: each +// leaf is opened under the keyset it was sealed with, with batched key +// retrievals per keyset (one per 500 leaves sealed under it). ct is the +// shape Cipher.Encrypt returns; aad must be what the value was sealed +// under. +func (c *Client) Decrypt(ctx context.Context, ct any, aad []byte) (any, error) { + return c.decryptValue(ctx, anyKeyset{}, ct, aad, false) +} + +// DecryptElement is Decrypt for a value sealed as a sequence element; see +// Cipher.DecryptElement. +func (c *Client) DecryptElement(ctx context.Context, ct any, aad []byte) (any, error) { + return c.decryptValue(ctx, anyKeyset{}, ct, aad, true) +} + +// DecryptRecords opens records produced by Cipher.EncryptRecords under any +// keyset of this client, into a slice; see Cipher.DecryptRecords. +func (c *Client) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...RecordOption) error { + return c.decryptRecords(ctx, anyKeyset{}, records, out, opts) +} + +// DecryptRecord opens one record under any keyset of this client; see +// Cipher.DecryptRecord. +func (c *Client) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...RecordOption) error { + return c.decryptRecord(ctx, anyKeyset{}, record, out, opts) +} + +// call runs f on the instance under the client's lock. +// +// A call interrupted by its context (the runtime closes the module on a +// deadline or cancellation, see newInstance) leaves the instance closed: +// its key material is gone with its memory and no further call can run. +// The client is then closed, so later calls are ErrState rather than a +// runtime error, and Close releases the runtime without a shutdown call. +// A guest that trapped is closed the same way, by this method: the guest +// builds with panic-as-abort, so a trap is an abort mid-export, after +// which its state is unknown and its keys are better wiped than reused. +func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed || c.inst.module.IsClosed() { + c.closed = true + return nil, ErrState + } + growth := c.inst.mem.GrowthRefusal() + if c.transport != nil { + c.transport.tokenErr = nil + } + out, err := f(c.inst) + switch { + case c.inst.module.IsClosed(): + c.closed = true + if err == nil { + err = ErrState + } + err = fmt.Errorf("%w: interrupted call closed the client", err) + case errors.Is(err, errGuestTrap): + // No guest code is running (f has returned), so the close is + // immediate: the module's memory is wiped and freed here. + c.closed = true + _ = c.inst.module.Close(context.Background()) + err = fmt.Errorf("%w; the client is closed", err) + } + // Under RequireLockedMemory a growth that cannot be locked is refused, + // and the guest sees only a failed allocation — or, for an allocation + // of its own, aborts, and the trap closed the client above. Name the + // real cause either way. The refusal is this call's, not the client's: + // the range went back unused, so MemoryLocked still holds. + if g := c.inst.mem.GrowthRefusal(); err != nil && g.Refused != growth.Refused { + err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", guest.MemoryLockError(g.Reason), err) + } + // The guest reports a failed token_get as a transport failure and no + // more; the token source said why. + if err != nil && c.transport != nil && c.transport.tokenErr != nil { + err = fmt.Errorf("%w (token source: %w)", err, c.transport.tokenErr) + } + if err != nil { + return nil, err + } + return out, nil +} + +func (c *Client) decryptValue(ctx context.Context, sel KeysetSelector, ct any, aad []byte, element bool) (any, error) { + encoded, err := marshalCipherText(ct) + if err != nil { + return nil, err + } + opts, err := vcffi.Marshal(options(sel)) + if err != nil { + return nil, err + } + out, err := c.call(ctx, func(inst *instance) ([]byte, error) { + fn := inst.decrypt + if element { + fn = inst.decryptElement + } + return inst.call(ctx, fn, buf(encoded), buf(aad), buf(opts)) + }) + if err != nil { + return nil, err + } + // The output is plaintext: decode, then wipe the transport copy. + defer wipe(out) + return vcffi.Unmarshal(out) +} diff --git a/languages/golang/stackencrypt/clientkey.go b/languages/golang/stackencrypt/clientkey.go new file mode 100644 index 000000000..ca7d3f683 --- /dev/null +++ b/languages/golang/stackencrypt/clientkey.go @@ -0,0 +1,20 @@ +package stackencrypt + +import "github.com/cipherstash/stack/languages/golang/internal/guest" + +// ClientKey is the ZeroKMS client key: long-lived key material that lives +// for the process. It is opaque — it prints a redaction under every verb +// and hands its bytes to no caller — and it is wiped once consumed. +// +// It is the one type both guest packages share: stackauth reads one out of +// the developer profile, and this package consumes it. The alias is what +// makes a key read there the type taken here without stackauth importing +// this package, so a binary that only wants the profile does not carry the +// crypto guest. +type ClientKey = guest.ClientKey + +// NewClientKey wraps key material — the CS_CLIENT_KEY hex form, or the +// base64 of secretkey.json — as a ClientKey. It takes ownership of b: the +// caller must not keep or reuse the slice, which is wiped along with the +// key. +func NewClientKey(b []byte) *ClientKey { return guest.NewClientKey(b) } diff --git a/languages/golang/stackencrypt/context.go b/languages/golang/stackencrypt/context.go new file mode 100644 index 000000000..4b8c6e750 --- /dev/null +++ b/languages/golang/stackencrypt/context.go @@ -0,0 +1,96 @@ +package stackencrypt + +import ( + "errors" + "fmt" +) + +// Context is the encryption context a record field or a term probe binds: +// a domain-separating value that becomes both the ciphertext's AAD (and +// the ZeroKMS descriptor the data key is bound to) and the index terms' +// PRF context. Contexts are identities, so a probe must spell the context +// in exactly the shape the field was sealed under. +// +// A Context is a part or a list of parts. A part is a string, a byte slice +// or an integer (int32, int64, uint32, uint64; Go's int is sent as int64). +// [NewContext] makes a one-part context — the bare part, the shape a Rust +// `#[derive(EncryptFrom)]` field is sealed under when the caller supplies no +// context of its own. [Context.With] extends it as Rust's NonEmpty::with +// does: the result is the two-element list [previous, part], nesting to the +// left, so NewContext("users/age").With(uint64(7)) is the context a row +// sealed with encrypt_into_with_context(row, 7u64) binds for that field. +// A one-element list is not the bare part, and this type cannot spell one. +type Context struct { + node any +} + +// NewContext makes a one-part context. The part must not be empty: a bare +// empty string or empty byte slice is an empty context, and the guest +// proves every context non-empty at the boundary, so such a Context could +// only ever fail — every call, with ErrEncoding. Rust refuses the same +// thing one step earlier: nonempty!("") does not compile. +// +// Emptiness is the whole tree's property, not the part's — a list is empty +// only when every part is — so [Context.With] may still add an empty part +// to a context that already has a non-empty one. Only the root is checked +// here. +func NewContext(part any) (Context, error) { + if err := checkPart(part); err != nil { + return Context{}, err + } + if err := checkRootNonEmpty(part); err != nil { + return Context{}, err + } + return Context{node: part}, nil +} + +// MustContext is [NewContext] for a part known to be valid; it panics +// otherwise, an empty part included. For string literals in plans and +// probes. +func MustContext(part any) Context { + c, err := NewContext(part) + if err != nil { + panic(err) + } + return c +} + +// With extends the context by one part, nesting to the left. +func (c Context) With(part any) (Context, error) { + if c.node == nil { + return Context{}, fmt.Errorf("stackencrypt: cannot extend an empty context") + } + if err := checkPart(part); err != nil { + return Context{}, err + } + return Context{node: []any{c.node, part}}, nil +} + +// value renders the context in the guest's grammar: a scalar or nested +// lists of scalars, ready for the transport codec. +func (c Context) value() any { return c.node } + +// checkRootNonEmpty refuses the bare parts that are themselves an empty +// context. Integers never are, whatever their value. +func checkRootNonEmpty(part any) error { + switch p := part.(type) { + case string: + if p == "" { + return errors.New("stackencrypt: an empty string is an empty context") + } + case []byte: + if len(p) == 0 { + return errors.New("stackencrypt: an empty byte slice is an empty context") + } + } + return nil +} + +func checkPart(part any) error { + switch part.(type) { + case string, []byte, int32, int64, uint32, uint64, int: + return nil + default: + return fmt.Errorf("stackencrypt: %T is not a context part (string, []byte or integer)", part) + } +} diff --git a/languages/golang/stackencrypt/credentials.go b/languages/golang/stackencrypt/credentials.go new file mode 100644 index 000000000..c1669d6c3 --- /dev/null +++ b/languages/golang/stackencrypt/credentials.go @@ -0,0 +1,429 @@ +package stackencrypt + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "sync/atomic" + + "github.com/cipherstash/stack/languages/golang/stackauth" +) + +// Credentials is where a [Client]'s ZeroKMS credentials come from: the +// client id, the client key, and the stackauth strategy that supplies the +// bearer token. NewClient resolves them once, host-side — the crypto guest +// is never given the environment or a filesystem to look them up itself — +// and hands the key to the guest. +// +// [AutoCredentials] is the default: the environment, then the developer +// profile, in the Rust client's order. [NewCredentials] takes a client id, +// a client key and a strategy explicitly; [OIDCFederation] mints the token +// from an identity provider's. Those three are the only implementations: +// the interface is sealed, so a bearer token always comes from a stackauth +// strategy. A raw token cannot be refreshed when it expires, and a source +// outside the strategies would bypass the cross-process refresh lock the +// device session shares with the CLI (a refresh token used twice gets the +// whole chain revoked). +type Credentials interface { + // resolve produces the credentials for one client. NewClient calls it + // once, and consumes the key it returns. A result returned alongside + // an error is consumed too: its key is wiped and its Close is called, + // so an implementation may hand back what it built before it failed + // rather than release it itself. + resolve(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) +} + +// resolveOptions is what NewClient tells a [Credentials] about the client +// it is resolving for, so a source that makes requests or holds key +// material of its own can do so under the client's settings. +type resolveOptions struct { + // Transport is the client's RoundTripper (never nil). AutoCredentials + // sends its authentication requests through it too. + Transport http.RoundTripper + // RequireLockedMemory is the client's setting. AutoCredentials applies + // it to the credential guest, which holds the client key while it + // reads it and the token strategy for the life of the client. + RequireLockedMemory bool +} + +// resolvedCredentials is one client's credentials, as a [Credentials] +// resolved them. +type resolvedCredentials struct { + // ClientID is the ZeroKMS client id (a UUID string). + ClientID string + // ClientKey is the client key. NewClient consumes it whatever the + // outcome, as [NewClientKey] describes. + ClientKey *ClientKey + // Token supplies the bearer token for every request: a stackauth + // strategy, outside the package's own tests. + Token tokenSource + // Close, when not nil, releases what the credentials hold open — the + // profile's guest and a refreshing token strategy, for AutoCredentials. + // The client calls it from Client.Close, or from NewClient when the + // client is not made. A Token that outlives it must not be asked again. + Close func() error + // MemoryLockError, when not nil, reports why memory the credentials + // hold key material in is not locked in RAM: the credential guest's, + // which the token strategy lives in and (for AutoCredentials and + // OIDCFederation) the client key passed through. AutoCredentials and + // OIDCFederation hand over their profile's ProfileStore.MemoryLockError; + // NewCredentials, the caller's strategy's Strategy.MemoryLockError, + // which is its store's. It is asked each time, not once: under + // best-effort locking a guest's memory can become unlocked later, when + // a growth for a token exchange or a refresh cannot be locked. NewClient + // refuses the credentials with it under WithRequireLockedMemory, and the + // client folds its answer into Client.MemoryLocked and + // Client.MemoryLockError, so a checklist asserting the lock sees every + // guest the key was in, not only the crypto guest. Nil, or returning + // nil, when the memory is locked or the credentials hold nothing. + MemoryLockError func() error +} + +// ErrNoCredentials is [AutoCredentials] finding no token strategy or no +// client key in either place it looks. The wrapped error says which, and +// what to set. +var ErrNoCredentials = errors.New("stackencrypt: no credentials") + +// NewCredentials is [Credentials] from explicit values: a client id, a +// client key (from [NewClientKey], or stackauth's typed read), and the +// stackauth strategy that supplies the bearer token (ProfileStore's +// AccessKey, DeviceSession, OIDC or Auto). The key is consumed by the first +// NewClient given these credentials; a second is refused with +// [ErrCredentialsConsumed], as a key is for one client. A nil strategy is +// refused by NewClient. +// +// The caller opened the strategy's ProfileStore and the strategy, and keeps +// them: the client asks the strategy for a token on every ZeroKMS request +// but never closes it or the store. Both must stay open until Client.Close +// has returned, and are the caller's to close after it — the strategy, then +// the store (closing the store closes its strategies too). A token asked of +// a closed strategy is an error from the operation that needed it. +func NewCredentials(clientID string, key *ClientKey, strategy *stackauth.Strategy) Credentials { + c := &explicitCredentials{clientID: clientID, key: key} + // A nil *Strategy stored in the interface would be a non-nil source + // that fails on first use; left unset, NewClient refuses it up front. + if strategy != nil { + c.token = strategy + } + return c +} + +// ErrCredentialsConsumed is [NewCredentials] given to a second NewClient: +// the first consumed its key — whether it made a client or refused its +// config — so there is nothing left to give. Build new credentials, with a +// new key, for another client. +var ErrCredentialsConsumed = errors.New("stackencrypt: the credentials' client key was already consumed by an earlier NewClient") + +// explicitCredentials is NewCredentials. token is the strategy; only this +// package's tests put anything else in it. +type explicitCredentials struct { + clientID string + key *ClientKey + token tokenSource + // consumed is set by the first resolve: what it handed out is the + // caller's to wipe, and a second caller must not be told its values + // were missing when they were spent. + consumed atomic.Bool +} + +func (c *explicitCredentials) resolve(context.Context, resolveOptions) (*resolvedCredentials, error) { + if !c.consumed.CompareAndSwap(false, true) { + return nil, ErrCredentialsConsumed + } + // A nil token never gets here: NewClient refuses it host-side, before + // the guest is read. + resolved := &resolvedCredentials{ClientID: c.clientID, ClientKey: c.key, Token: c.token} + if strategy, ok := c.token.(*stackauth.Strategy); ok { + // The strategy's store is the guest the token lives in and, when + // the key was read through it, the key passed through: reported + // live, as AutoCredentials reports its profile's. + resolved.MemoryLockError = strategy.MemoryLockError + } + // No Close: the strategy and its store are the caller's. + return resolved, nil +} + +// String names the credentials' kind and client id; the key prints a +// redaction under every verb anyway, but nothing here asks it to. +func (c *explicitCredentials) String() string { + return fmt.Sprintf("stackencrypt.NewCredentials{client_id: %s}", c.clientID) +} + +// The environment variables AutoCredentials and NewClient read. The profile +// directory's own, CS_CONFIG_PATH, is stackauth's; so is CS_CTS_HOST, which +// its strategies take as the authentication endpoint. +const ( + envClientID = "CS_CLIENT_ID" + envClientKey = "CS_CLIENT_KEY" + envAccessKey = "CS_CLIENT_ACCESS_KEY" + envWorkspaceCRN = "CS_WORKSPACE_CRN" +) + +// envZeroKMSHost is the endpoint override, in the order stack-kms reads it: +// the first that is set decides, and CS_VITUR_HOST is the legacy name. +var envZeroKMSHost = []string{"CS_ZEROKMS_HOST", "CS_VITUR_HOST"} + +// AutoCredentials is [Credentials] from the environment first, then the +// developer profile `stash auth login` writes — the order the Rust client +// (stack-encrypt's StackCipher::builder().init()) resolves them in, so one +// set of variables configures a service in either language: +// +// - The token, by stack-auth's AutoStrategy order: CS_CLIENT_ACCESS_KEY +// (with CS_WORKSPACE_CRN, which is then required) exchanged for a token; +// else the current workspace's stored device session, refreshed under +// the same cross-process lock as the CLI. CS_CTS_HOST overrides the +// authentication endpoint. +// - The client key: CS_CLIENT_ID and CS_CLIENT_KEY when both are set (the +// hex form, or the base64 of secretkey.json); else the current +// workspace's secretkey.json. Only one of the two set is the same as +// neither. Set but empty is an error, not a fall-through. +// +// The profile is CS_CONFIG_PATH, else ~/.cipherstash; a profile that cannot +// be opened is not an error — an environment-only deployment has none — it +// just leaves the environment as the only source, as the Rust client does. +// Nothing found in either place is [ErrNoCredentials], and when the profile +// would have been consulted, that error says why it could not be: a +// directory that does not exist, one that cannot be read, a path that is +// not a directory. +// +// The profile and the token strategies run in stackauth's credential guest, +// not in the crypto guest, which still sees no environment and no +// filesystem. The credential guest lives as long as the client, and +// Client.Close releases it. +func AutoCredentials() Credentials { return autoCredentials{} } + +type autoCredentials struct{} + +// String names the credentials' kind; nothing is resolved to print it. +func (autoCredentials) String() string { return "stackencrypt.AutoCredentials" } + +func (autoCredentials) resolve(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + return resolveWithStrategy(ctx, opts, func(ctx context.Context, profile *stackauth.ProfileStore, noProfile error) (*stackauth.Strategy, error) { + strategy, err := profile.Auto(ctx) + switch { + case errors.Is(err, stackauth.ErrNotAuthenticated): + if noProfile != nil { + err = fmt.Errorf("%w: %w", err, noProfile) + } + return nil, fmt.Errorf("%w: no token: set %s and %s, or run `stash auth login`: %w", + ErrNoCredentials, envAccessKey, envWorkspaceCRN, err) + case errors.Is(err, stackauth.ErrAuthConfig) && accessKeyConfigured(): + // The status covers every configuration fault the guest reports; + // name the variables only when they are what was configured. + return nil, fmt.Errorf("stackencrypt: credentials: check %s and %s: %w", envAccessKey, envWorkspaceCRN, err) + case err != nil: + return nil, fmt.Errorf("stackencrypt: credentials: %w", err) + } + return strategy, nil + }) +} + +// OIDCFederation is [Credentials] whose token is minted by federation: CTS +// exchanges a token from the application's own identity provider (Clerk, +// Auth0, Okta, a cloud workload identity) for a CipherStash one in the +// workspace crn names. provider is asked for a fresh IdP token only when a +// CipherStash token has to be minted; stackauth.OAuth2TokenSource adapts a +// golang.org/x/oauth2 source. The client key is resolved as +// [AutoCredentials] resolves it: CS_CLIENT_ID and CS_CLIENT_KEY, else the +// developer profile. +// +// opts configure the federation strategy as they would +// stackauth.ProfileStore.OIDC: stackauth.WithAuthBaseURL pins the CTS +// endpoint for these credentials alone. Without it, CS_CTS_HOST overrides +// the endpoint, else it is discovered. +func OIDCFederation(crn string, provider stackauth.OIDCProvider, opts ...stackauth.StrategyOption) Credentials { + return &oidcCredentials{crn: crn, provider: provider, opts: opts} +} + +type oidcCredentials struct { + crn string + provider stackauth.OIDCProvider + opts []stackauth.StrategyOption +} + +// String names the credentials' kind and workspace; the provider is not +// asked for anything to print it. +func (c oidcCredentials) String() string { + return fmt.Sprintf("stackencrypt.OIDCFederation{crn: %s}", c.crn) +} + +func (c oidcCredentials) resolve(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + return resolveWithStrategy(ctx, opts, func(ctx context.Context, profile *stackauth.ProfileStore, _ error) (*stackauth.Strategy, error) { + strategy, err := profile.OIDC(ctx, c.crn, c.provider, c.opts...) + if err != nil { + return nil, fmt.Errorf("stackencrypt: credentials: OIDC federation: %w", err) + } + return strategy, nil + }) +} + +// resolveWithStrategy opens the credential guest — over the profile when +// there is one, with nothing mounted when there is not — asks strategy for +// the token source (passing why there is no profile, when there is none), +// then resolves the client key from the environment or +// the profile. On success the guest and the strategy are the resolved +// credentials' to close; on failure they are closed here. +func resolveWithStrategy( + ctx context.Context, + opts resolveOptions, + strategy func(ctx context.Context, profile *stackauth.ProfileStore, noProfile error) (*stackauth.Strategy, error), +) (_ *resolvedCredentials, err error) { + authOpts := []stackauth.Option{stackauth.WithRoundTripper(opts.Transport)} + if opts.RequireLockedMemory { + authOpts = append(authOpts, stackauth.RequireLockedMemory()) + } + profile, err := stackauth.Resolve(ctx, authOpts...) + // noProfile is why there is no profile to consult, when there is none: + // kept for the errors that would have consulted it, so a profile that + // exists but cannot be opened is not reported as "not logged in". + var noProfile error + if errors.Is(err, stackauth.ErrNoProfile) { + // The strategies that need no profile still run, in a guest with + // nothing mounted; every profile read on it is ErrNoProfile. + noProfile = err + profile, err = stackauth.OpenWithoutProfile(ctx, authOpts...) + } + if errors.Is(err, ErrMemoryLock) { + // Under RequireLockedMemory the guest is refused before anything is + // resolved — on a host that cannot lock or reserve its memory at all + // (a 32-bit address space, a zero RLIMIT_MEMLOCK). Name which guest, + // as the client's own report does. + return nil, fmt.Errorf("stackencrypt: credentials: the credential guest: %w", err) + } + if err != nil { + return nil, fmt.Errorf("stackencrypt: credentials: %w", err) + } + defer func() { + if err != nil { + _ = profile.Close() + } + }() + + // The token first, as Rust detects its strategy before it asks the key + // provider: with neither configured, the error names the token. + token, err := strategy(ctx, profile, noProfile) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + _ = token.Close() + } + }() + + clientID, key, err := clientKeyFromEnv() + if err != nil { + return nil, err + } + if key == nil { + if clientID, key, err = clientKeyFromProfile(ctx, profile, noProfile); err != nil { + return nil, err + } + } + return &resolvedCredentials{ + ClientID: clientID, + ClientKey: key, + Token: token, + Close: func() error { + // The strategy lives in the profile's guest, which closing the + // profile would free anyway; closing it first unregisters it + // cleanly. + return errors.Join(token.Close(), profile.Close()) + }, + MemoryLockError: profile.MemoryLockError, + }, nil +} + +// accessKeyConfigured reports whether either variable of the access-key +// strategy is set: what its configuration errors are then about. +func accessKeyConfigured() bool { + _, keySet := os.LookupEnv(envAccessKey) + _, crnSet := os.LookupEnv(envWorkspaceCRN) + return keySet || crnSet +} + +// clientKeyFromEnv is stack-kms's EnvKeyProvider: both variables set is the +// key, either unset is no key (nil, and the profile is asked), and a set but +// empty value is an error, since falling through would quietly use a +// different key from the one the operator configured. The value is never +// part of an error. +func clientKeyFromEnv() (string, *ClientKey, error) { + id, ok := os.LookupEnv(envClientID) + if !ok { + return "", nil, nil + } + material, ok := os.LookupEnv(envClientKey) + if !ok { + return "", nil, nil + } + if id == "" || material == "" { + return "", nil, fmt.Errorf("%w: %s and %s are set, but one of them is empty", ErrEncoding, envClientID, envClientKey) + } + // The environment's string is Go's and cannot be wiped; this copy can, + // and the ClientKey owns it. + return id, NewClientKey([]byte(material)), nil +} + +// clientKeyFromProfile is the current workspace's secretkey.json. The +// "nothing there" answers — no profile, no current workspace, no file — are +// ErrNoCredentials; a file that is there but unreadable keeps its own error. +// noProfile, when not nil, is why the profile could not be opened; the +// store's own answer to a read is then a bare ErrNoProfile, and the reason +// is the useful one. +func clientKeyFromProfile(ctx context.Context, profile *stackauth.ProfileStore, noProfile error) (string, *ClientKey, error) { + notConfigured := func(err error) error { + return fmt.Errorf("%w: no client key: set %s and %s, or run `stash auth login`: %w", + ErrNoCredentials, envClientID, envClientKey, err) + } + workspace, err := profile.CurrentWorkspaceStore(ctx) + if errors.Is(err, stackauth.ErrNoProfile) && noProfile != nil { + err = noProfile + } + if errors.Is(err, stackauth.ErrNoProfile) || errors.Is(err, stackauth.ErrNoCurrentWorkspace) { + return "", nil, notConfigured(err) + } + if err != nil { + return "", nil, fmt.Errorf("stackencrypt: credentials: %w", err) + } + clientID, key, err := workspace.SecretKey(ctx) + if errors.Is(err, stackauth.ErrNotFound) { + return "", nil, notConfigured(err) + } + if err != nil { + return "", nil, fmt.Errorf("stackencrypt: credentials: reading the client key: %w", err) + } + return clientID, key, nil +} + +// zerokmsEndpoint is the endpoint NewClient pins, in stack-kms's order: the +// explicit URL, else the first of CS_ZEROKMS_HOST and CS_VITUR_HOST that is +// set, else none (the token's services claim decides on first use). A set +// variable that is not a usable endpoint is an error, not a fall-through: +// falling through would send key operations somewhere the operator did not +// configure. The guest validates the URL in full; this check exists to name +// the variable, and never prints the value, which could carry userinfo. +func zerokmsEndpoint(explicit string) (string, error) { + if explicit != "" { + return explicit, nil + } + for _, name := range envZeroKMSHost { + value, ok := os.LookupEnv(name) + if !ok { + continue + } + u, err := url.Parse(value) + switch { + case err != nil: + return "", fmt.Errorf("%w: %s is not a URL", ErrEncoding, name) + case u.Scheme != "http" && u.Scheme != "https": + return "", fmt.Errorf("%w: %s must be an http or https URL", ErrEncoding, name) + case u.Host == "": + return "", fmt.Errorf("%w: %s has no host", ErrEncoding, name) + } + return value, nil + } + return "", nil +} diff --git a/languages/golang/stackencrypt/credentials_test.go b/languages/golang/stackencrypt/credentials_test.go new file mode 100644 index 000000000..b04911e64 --- /dev/null +++ b/languages/golang/stackencrypt/credentials_test.go @@ -0,0 +1,905 @@ +package stackencrypt + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/stackauth" +) + +// Credential resolution, pinned against the Rust client's order. The +// sources the order is taken from, so a change there is a change here: +// +// - the token: stack-auth's AutoStrategy::detect_inner — an access key +// from CS_CLIENT_ACCESS_KEY (CS_WORKSPACE_CRN then required), else the +// current workspace's auth.json, else NotAuthenticated; +// - the client key: stack-encrypt's client_key_provider (cipher.rs) — +// FallbackKeyProvider(EnvKeyProvider, the profile), falling through only +// on "not configured": both CS_CLIENT_ID and CS_CLIENT_KEY set, else the +// current workspace's secretkey.json; a set but unusable value is an +// error, not a fall-through; an unresolvable profile is not an error; +// - the order of the two: StackCipherBuilder::init detects the strategy +// (StackKmsBuilder::auto) before it asks the key provider (build); +// - the endpoint: stack-kms's StackKmsBuilder::base_url_from_env — an +// explicit URL, else the first of CS_ZEROKMS_HOST and CS_VITUR_HOST that +// is set, an unusable value an error; else the token's services claim. + +const ( + testCRN = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" + testAccessKey = "CSAKtestKeyId.testKeySecret" + testWorkspace = "AAAAAAAAAAAAAAAA" + // The profile's client id: distinct from testClientID, so a test can + // tell which source a key came from. + profileClientID = "0b1e8a44-5c1d-4d4e-9b52-3f0e6c2f8a17" +) + +// authGuestOrSkip skips where stackauth's credential guest is not built, +// as guestOrSkip does for this package's own. +func authGuestOrSkip(t *testing.T) { + t.Helper() + store, err := stackauth.OpenWithoutProfile(context.Background()) + if errors.Is(err, stackauth.ErrGuestNotBuilt) { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + _ = store.Close() +} + +// cleanEnv clears every variable credential resolution reads, and points +// the profile at dir, so no test sees the developer's own credentials. +// t.Setenv restores each at the end of the test. +func cleanEnv(t *testing.T, dir string) { + t.Helper() + for _, name := range []string{ + envClientID, envClientKey, envAccessKey, envWorkspaceCRN, + "CS_ZEROKMS_HOST", "CS_VITUR_HOST", "CS_CTS_HOST", "CS_CONFIG_PATH", + } { + t.Setenv(name, "") + if err := os.Unsetenv(name); err != nil { + t.Fatal(err) + } + } + t.Setenv("CS_CONFIG_PATH", dir) + // The stored test tokens are not JWTs, so the device session cannot + // discover CTS from one; name it. Nothing is sent there while a token + // is fresh. newAuthServer points it at itself. + t.Setenv("CS_CTS_HOST", "https://cts.example.com") +} + +// profileFiles is what a login leaves in a workspace. An empty field is a +// file not written. +type profileFiles struct { + secretKey string + auth string +} + +// loggedIn is the profile `stash auth login` leaves: a current workspace +// holding the test client key under profileClientID and a fresh token. +func loggedIn(token string) profileFiles { + return profileFiles{ + secretKey: fmt.Sprintf(`{"client_id":%q,"client_key":%q}`, profileClientID, + base64.StdEncoding.EncodeToString(mustHex(testClientKey))), + auth: fmt.Sprintf(`{"access_token":%q,"refresh_token":"refresh","token_type":"Bearer","expires_at":%d,"region":"ap-southeast-2.aws","client_id":"cli"}`, + token, time.Now().Add(time.Hour).Unix()), + } +} + +// newProfile writes a profile directory with testWorkspace current and +// files in it, and returns the directory. +func newProfile(t *testing.T, files profileFiles) string { + t.Helper() + dir := t.TempDir() + ws := filepath.Join(dir, "workspaces", testWorkspace) + if err := os.MkdirAll(ws, 0o700); err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{"secretkey.json": files.secretKey, "auth.json": files.auth} { + if content != "" { + if err := os.WriteFile(filepath.Join(ws, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + } + store, err := stackauth.Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err := store.SetCurrentWorkspace(context.Background(), testWorkspace); err != nil { + t.Fatal(err) + } + return dir +} + +// authServer is CTS's access-key exchange: it answers every /api/authorise +// with a fresh JWT, and counts the exchanges. +type authServer struct { + *httptest.Server + jwt string + calls atomic.Int32 +} + +func newAuthServer(t *testing.T) *authServer { + t.Helper() + s := &authServer{} + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/authorise" { + t.Errorf("unexpected auth request %s", r.URL.Path) + http.NotFound(w, r) + return + } + s.calls.Add(1) + fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, s.jwt, time.Now().Add(time.Hour).Unix()) + })) + t.Cleanup(s.Close) + payload, err := json.Marshal(map[string]any{ + "iss": s.URL, "sub": "CS|test", "workspace": "ZVATKW3VHMFG27DY", + "exp": time.Now().Add(time.Hour).Unix(), + }) + if err != nil { + t.Fatal(err) + } + s.jwt = "e30." + base64.RawURLEncoding.EncodeToString(payload) + ".c2ln" + t.Setenv("CS_CTS_HOST", s.URL) + return s +} + +// resolve runs AutoCredentials and releases what it holds at the end of +// the test. +func resolve(t *testing.T) (*resolvedCredentials, error) { + t.Helper() + resolved, err := AutoCredentials().resolve(context.Background(), resolveOptions{Transport: http.DefaultTransport}) + if err == nil { + t.Cleanup(func() { _ = resolved.Close() }) + } + return resolved, err +} + +func token(t *testing.T, resolved *resolvedCredentials) string { + t.Helper() + tok, err := resolved.Token.Token(context.Background()) + if err != nil { + t.Fatalf("Token: %v", err) + } + return tok +} + +func TestAutoCredentialsFromTheProfile(t *testing.T) { + authGuestOrSkip(t) + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + resolved, err := resolve(t) + if err != nil { + t.Fatal(err) + } + if resolved.ClientID != profileClientID { + t.Errorf("ClientID = %q, want the profile's", resolved.ClientID) + } + if got := string(guest.KeyBytes(resolved.ClientKey)); got != base64.StdEncoding.EncodeToString(mustHex(testClientKey)) { + t.Error("ClientKey is not the profile's secretkey.json") + } + if got := token(t, resolved); got != "profile-token" { + t.Errorf("Token = %q, want the stored device session's", got) + } + // The credential guest's lock state travels with the credentials, so + // the client can report it; what it is depends on the host. + if resolved.MemoryLockError == nil { + t.Fatal("MemoryLockError is not set: the credential guest's lock state is not reported") + } + if err := resolved.MemoryLockError(); err != nil && !errors.Is(err, ErrMemoryLock) { + t.Errorf("MemoryLockError() = %v, want nil or ErrMemoryLock", err) + } +} + +func TestAutoCredentialsFromTheEnvironmentWithNoProfile(t *testing.T) { + authGuestOrSkip(t) + // The CI shape: four variables and no profile directory at all. + cleanEnv(t, filepath.Join(t.TempDir(), "absent")) + auth := newAuthServer(t) + t.Setenv(envAccessKey, testAccessKey) + t.Setenv(envWorkspaceCRN, testCRN) + t.Setenv(envClientID, testClientID) + t.Setenv(envClientKey, testClientKey) + resolved, err := resolve(t) + if err != nil { + t.Fatal(err) + } + if resolved.ClientID != testClientID || string(guest.KeyBytes(resolved.ClientKey)) != testClientKey { + t.Error("the client key is not the environment's") + } + if got := token(t, resolved); got != auth.jwt || auth.calls.Load() != 1 { + t.Errorf("Token = %q after %d exchanges, want the access key's", got, auth.calls.Load()) + } +} + +func TestAutoCredentialsPrecedence(t *testing.T) { + authGuestOrSkip(t) + t.Run("the environment's client key wins over the profile's", func(t *testing.T) { + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + t.Setenv(envClientID, testClientID) + t.Setenv(envClientKey, testClientKey) + resolved, err := resolve(t) + if err != nil { + t.Fatal(err) + } + if resolved.ClientID != testClientID || string(guest.KeyBytes(resolved.ClientKey)) != testClientKey { + t.Error("the client key is not the environment's") + } + // The token still comes from the profile: the halves resolve apart. + if got := token(t, resolved); got != "profile-token" { + t.Errorf("Token = %q, want the profile's", got) + } + }) + t.Run("one of the two key variables is the same as neither", func(t *testing.T) { + for _, name := range []string{envClientID, envClientKey} { + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + value := testClientID + if name == envClientKey { + value = testClientKey + } + t.Setenv(name, value) + resolved, err := resolve(t) + if err != nil { + t.Fatal(err) + } + if resolved.ClientID != profileClientID { + t.Errorf("only %s set: ClientID = %q, want the profile's", name, resolved.ClientID) + } + } + }) + t.Run("the environment's access key wins over the stored session", func(t *testing.T) { + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + auth := newAuthServer(t) + t.Setenv(envAccessKey, testAccessKey) + t.Setenv(envWorkspaceCRN, testCRN) + resolved, err := resolve(t) + if err != nil { + t.Fatal(err) + } + if got := token(t, resolved); got != auth.jwt { + t.Errorf("Token = %q, want the access key's", got) + } + // The key still comes from the profile. + if resolved.ClientID != profileClientID { + t.Errorf("ClientID = %q, want the profile's", resolved.ClientID) + } + }) +} + +func TestAutoCredentialsMissing(t *testing.T) { + authGuestOrSkip(t) + for _, tc := range []struct { + name string + // profile is nil for no profile directory at all. + profile *profileFiles + env map[string]string + want []error + names string + }{ + { + name: "nothing anywhere", + want: []error{ErrNoCredentials, stackauth.ErrNotAuthenticated}, + names: envAccessKey, + }, + { + name: "a profile with no login", + profile: &profileFiles{}, + want: []error{ErrNoCredentials, stackauth.ErrNotAuthenticated}, + names: "stash auth login", + }, + { + // The token is there and the key is not: the key's error, naming + // the variables that would supply it. + name: "an access key and no client key", + env: map[string]string{envAccessKey: testAccessKey, envWorkspaceCRN: testCRN}, + want: []error{ErrNoCredentials}, + names: envClientKey, + }, + { + name: "a stored session and no secretkey.json", + profile: &profileFiles{auth: loggedIn("t").auth}, + want: []error{ErrNoCredentials, stackauth.ErrNotFound}, + names: envClientKey, + }, + { + name: "an access key with no workspace CRN", + env: map[string]string{envAccessKey: testAccessKey, envClientID: testClientID, envClientKey: testClientKey}, + want: []error{stackauth.ErrAuthConfig}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "absent") + if tc.profile != nil { + dir = newProfile(t, *tc.profile) + } + cleanEnv(t, dir) + for k, v := range tc.env { + t.Setenv(k, v) + } + _, err := resolve(t) + for _, want := range tc.want { + if !errors.Is(err, want) { + t.Errorf("error %v, want %v", err, want) + } + } + if err != nil && !strings.Contains(err.Error(), tc.names) { + t.Errorf("error %q does not name %q", err, tc.names) + } + }) + } +} + +// A profile that exists but cannot be opened is not "not logged in": the +// error says why the profile could not be consulted — here, a +// CS_CONFIG_PATH that names a file — wherever the profile would have +// supplied the missing half. +func TestAutoCredentialsNamesWhyTheProfileCouldNotBeOpened(t *testing.T) { + authGuestOrSkip(t) + file := filepath.Join(t.TempDir(), "profile") + if err := os.WriteFile(file, nil, 0o600); err != nil { + t.Fatal(err) + } + t.Run("the token", func(t *testing.T) { + cleanEnv(t, file) + _, err := resolve(t) + for _, want := range []error{ErrNoCredentials, stackauth.ErrNoProfile} { + if !errors.Is(err, want) { + t.Errorf("error %v, want %v", err, want) + } + } + if err == nil || !strings.Contains(err.Error(), "is not a directory") || !strings.Contains(err.Error(), file) { + t.Errorf("error %q does not say why %s could not be opened", err, file) + } + }) + t.Run("the client key", func(t *testing.T) { + cleanEnv(t, file) + t.Setenv(envAccessKey, testAccessKey) + t.Setenv(envWorkspaceCRN, testCRN) + _, err := resolve(t) + for _, want := range []error{ErrNoCredentials, stackauth.ErrNoProfile} { + if !errors.Is(err, want) { + t.Errorf("error %v, want %v", err, want) + } + } + if err == nil || !strings.Contains(err.Error(), "is not a directory") || !strings.Contains(err.Error(), envClientKey) { + t.Errorf("error %q does not name %s and say why the profile could not be opened", err, envClientKey) + } + }) +} + +// A set but empty variable is refused rather than skipped, as stack-kms's +// EnvKeyProvider refuses it; and no error from resolution carries the +// material of a key the environment or the profile held. +func TestAutoCredentialsRefusesAnEmptyKeyVariableWithoutPrintingKeys(t *testing.T) { + authGuestOrSkip(t) + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + t.Setenv(envClientID, "") + t.Setenv(envClientKey, testClientKey) + _, err := resolve(t) + if !errors.Is(err, ErrEncoding) { + t.Fatalf("empty %s: %v, want ErrEncoding, not the profile's key", envClientID, err) + } + profileKey := base64.StdEncoding.EncodeToString(mustHex(testClientKey)) + for _, leak := range []string{testClientKey[:16], profileKey[:16]} { + if strings.Contains(err.Error(), leak) { + t.Fatalf("the error carries key material: %q", err) + } + } +} + +// The credentials NewClient resolved are the client's: the token source it +// asks is theirs, and Close releases them. Nothing printed of the client or +// of a failed NewClient carries the key. +func TestNewClientWithAutoCredentials(t *testing.T) { + guestOrSkip(t) + authGuestOrSkip(t) + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + // The endpoint from the environment, since no option names one. + t.Setenv("CS_ZEROKMS_HOST", stub.URL) + var released *resolvedCredentials + creds := credentialsFunc(func(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + r, err := AutoCredentials().resolve(ctx, opts) + released = r + return r, err + }) + _, err := NewClient(context.Background(), WithCredentials(creds)) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized from the stub", err) + } + if len(stub.requests) != 1 || stub.requests[0].auth != "Bearer profile-token" { + t.Fatalf("requests = %+v, want one bearing the profile's token", stub.requests) + } + if strings.Contains(err.Error(), testClientKey[:16]) { + t.Fatalf("the error carries key material: %q", err) + } + // A failed NewClient released the credentials it resolved. + if _, err := released.Token.Token(context.Background()); !errors.Is(err, stackauth.ErrState) { + t.Fatalf("the token source after a failed NewClient: %v, want ErrState", err) + } +} + +// A failed NewClient releases the credentials once, through the client it +// made and closed — the same wiring a successful client's Close runs — and +// not again on its way out. +func TestNewClientReleasesTheCredentialsOnceWhenInitFails(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + var released int + creds := credentialsFunc(func(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + r, err := testCredentials(staticToken("t")).resolve(ctx, opts) + if err == nil { + r.Close = func() error { released++; return nil } + } + return r, err + }) + _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL)) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized from the stub", err) + } + if released != 1 { + t.Fatalf("a failed NewClient released the credentials %d times, want once", released) + } +} + +// A Resolve that fails while handing back what it built is consumed as a +// successful one is: the key is wiped and Close runs, once. +func TestNewClientConsumesCredentialsAFailedResolveHandsBack(t *testing.T) { + // The resolve runs after the guest is read. + guestOrSkip(t) + key := NewClientKey([]byte(testClientKey)) + var released int + resolveErr := errors.New("the token strategy failed") + creds := credentialsFunc(func(context.Context, resolveOptions) (*resolvedCredentials, error) { + return &resolvedCredentials{ + ClientID: testClientID, + ClientKey: key, + Close: func() error { released++; return nil }, + }, resolveErr + }) + _, err := NewClient(context.Background(), WithCredentials(creds)) + if !errors.Is(err, resolveErr) { + t.Fatalf("NewClient: %v, want the Resolve error", err) + } + if !key.IsZero() { + t.Error("the key a failed Resolve handed back still holds material") + } + if released != 1 { + t.Errorf("the failed Resolve's Close ran %d times, want once", released) + } +} + +// The host-side checks of the config run before the credentials are asked: +// a refused endpoint or cache size costs no resolution — under +// AutoCredentials, no credential guest — and an explicit key is consumed +// all the same. +func TestNewClientRefusesTheConfigBeforeResolvingCredentials(t *testing.T) { + for name, options := range map[string]func(*testing.T) []ClientOption{ + "an unusable CS_ZEROKMS_HOST": func(t *testing.T) []ClientOption { + t.Setenv("CS_ZEROKMS_HOST", "localhost:3002") + return nil + }, + "a negative cache size": func(*testing.T) []ClientOption { + return []ClientOption{WithKeysetCacheSize(-1)} + }, + } { + t.Run(name, func(t *testing.T) { + cleanEnv(t, t.TempDir()) + opts := options(t) + resolved := false + spy := credentialsFunc(func(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + resolved = true + return testCredentials(staticToken("t")).resolve(ctx, opts) + }) + if _, err := NewClient(context.Background(), append([]ClientOption{WithCredentials(spy)}, opts...)...); !errors.Is(err, ErrEncoding) { + t.Fatalf("NewClient: %v, want ErrEncoding", err) + } + if resolved { + t.Error("the credentials were resolved for a config refused host-side") + } + key := NewClientKey([]byte(testClientKey)) + explicit := newTestCredentials(testClientID, key, staticToken("t")) + if _, err := NewClient(context.Background(), append([]ClientOption{WithCredentials(explicit)}, opts...)...); err == nil { + t.Fatal("NewClient accepted the config") + } + if !key.IsZero() { + t.Error("an explicit key was handed back live with the refused config") + } + }) + } +} + +// A config refused before the credentials are asked still consumes explicit +// credentials: the retry with the config corrected is refused because the +// key was spent, not told the key it was given is missing, and sends no +// request. +func TestNewCredentialsRefusedConfigThenRetryIsConsumed(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + creds := testCredentials(staticToken("t")) + if _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL), WithKeysetCacheSize(-1)); err == nil { + t.Fatal("NewClient accepted a negative cache size") + } + _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL)) + if !errors.Is(err, ErrCredentialsConsumed) { + t.Fatalf("retry with a corrected config: %v, want ErrCredentialsConsumed", err) + } + if strings.Contains(err.Error(), "required") { + t.Errorf("the error blames missing values: %q", err) + } + if len(stub.requests) != 0 { + t.Errorf("the refused config and its retry made %d requests, want none", len(stub.requests)) + } +} + +// The client's memory report covers the credentials' memory too: a lock the +// credential guest could not get is a lock the client did not get, wherever +// the client is asked, printed or logged. +func TestClientReportsTheCredentialsMemoryLock(t *testing.T) { + wasm := guestOrSkip(t) + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: staticToken("t")}, guest.BestEffort) + if err != nil { + t.Fatal(err) + } + c := newClient(inst, nil) + t.Cleanup(func() { _ = c.Close() }) + if err := c.MemoryLockError(); err != nil { + t.Skipf("this guest's own memory is unlocked here (%v); the fold cannot be told apart", err) + } + // The credentials' state is asked each time: what was locked at + // NewClient can become unlocked on a later growth, and the client's + // report follows it. + var lockErr error + c.credentialsLockErr = func() error { return lockErr } + if !c.MemoryLocked() { + t.Fatalf("MemoryLocked false while the credentials report locked: %v", c.MemoryLockError()) + } + lockErr = guest.MemoryLockError(errors.New("RLIMIT_MEMLOCK refused the credential guest")) + if c.MemoryLocked() { + t.Fatal("MemoryLocked with the credentials' memory unlocked") + } + if err := c.MemoryLockError(); !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "credential guest") { + t.Fatalf("MemoryLockError = %v, want ErrMemoryLock naming the credential guest", err) + } + if s := fmt.Sprint(c); !strings.Contains(s, "unlocked") || !strings.Contains(s, "credential guest") { + t.Fatalf("Client prints as %q: no credentials' memory state", s) + } + if v := c.LogValue().String(); !strings.Contains(v, "memory_locked=false") || !strings.Contains(v, "credential guest") { + t.Fatalf("Client logs as %q: no credentials' memory state", v) + } +} + +// A second NewClient given the same NewCredentials is refused for the +// reason that holds — the key was consumed by the first — not for values +// the caller did supply. +func TestNewCredentialsRefusesASecondClient(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + creds := testCredentials(staticToken("t")) + cfg := []ClientOption{WithCredentials(creds), withZeroKMSURL(stub.URL)} + if _, err := NewClient(context.Background(), cfg...); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("first NewClient: %v, want ErrUnauthorized from the stub", err) + } + _, err := NewClient(context.Background(), cfg...) + if !errors.Is(err, ErrCredentialsConsumed) { + t.Fatalf("second NewClient: %v, want ErrCredentialsConsumed", err) + } + if strings.Contains(err.Error(), "required") { + t.Errorf("the error blames missing values: %q", err) + } + if len(stub.requests) != 1 { + t.Errorf("the second NewClient made a request: %d in all", len(stub.requests)) + } +} + +// NewClient with no options is AutoCredentials: with nothing configured, +// the error is the resolution's, before the crypto guest is instantiated +// or a request made. +func TestNewClientDefaultsToAutoCredentials(t *testing.T) { + // The resolve runs after the guest is read. + guestOrSkip(t) + authGuestOrSkip(t) + cleanEnv(t, filepath.Join(t.TempDir(), "absent")) + if _, err := NewClient(context.Background()); !errors.Is(err, ErrNoCredentials) { + t.Fatalf("NewClient with no credentials anywhere: %v, want ErrNoCredentials", err) + } +} + +func TestZeroKMSEndpointOrder(t *testing.T) { + const explicit, primary, legacy = "https://explicit.example", "https://primary.example", "https://legacy.example" + for _, tc := range []struct { + name string + explicit string + primary, legacy *string + want string + wantErr, errName string + }{ + {name: "none", want: ""}, + {name: "explicit wins", explicit: explicit, primary: ptr(primary), legacy: ptr(legacy), want: explicit}, + {name: "CS_ZEROKMS_HOST over the legacy name", primary: ptr(primary), legacy: ptr(legacy), want: primary}, + {name: "the legacy name alone", legacy: ptr(legacy), want: legacy}, + // Set decides, not non-empty: an empty primary is an error, and + // the legacy name is not consulted. + {name: "set but empty", primary: ptr(""), legacy: ptr(legacy), errName: "CS_ZEROKMS_HOST"}, + {name: "no scheme", primary: ptr("localhost:3002"), errName: "CS_ZEROKMS_HOST"}, + {name: "not http", legacy: ptr("ftp://zerokms.example"), errName: "CS_VITUR_HOST"}, + {name: "no host", primary: ptr("https://"), errName: "CS_ZEROKMS_HOST"}, + {name: "userinfo is not printed", primary: ptr("https://user:hunter2@%zz"), errName: "CS_ZEROKMS_HOST"}, + } { + t.Run(tc.name, func(t *testing.T) { + cleanEnv(t, t.TempDir()) + if tc.primary != nil { + t.Setenv("CS_ZEROKMS_HOST", *tc.primary) + } + if tc.legacy != nil { + t.Setenv("CS_VITUR_HOST", *tc.legacy) + } + got, err := zerokmsEndpoint(tc.explicit) + if tc.errName != "" { + if !errors.Is(err, ErrEncoding) || !strings.Contains(err.Error(), tc.errName) { + t.Fatalf("error %v, want ErrEncoding naming %s", err, tc.errName) + } + if strings.Contains(err.Error(), "hunter2") { + t.Fatalf("the error prints the URL: %q", err) + } + return + } + if err != nil || got != tc.want { + t.Fatalf("zerokmsEndpoint = %q, %v; want %q", got, err, tc.want) + } + }) + } +} + +// Close releases what the credentials hold: the token strategy and the +// profile's guest. +func TestAutoCredentialsCloseReleasesTheGuest(t *testing.T) { + authGuestOrSkip(t) + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + resolved, err := AutoCredentials().resolve(context.Background(), resolveOptions{Transport: http.DefaultTransport}) + if err != nil { + t.Fatal(err) + } + if err := resolved.Close(); err != nil { + t.Fatal(err) + } + if _, err := resolved.Token.Token(context.Background()); !errors.Is(err, stackauth.ErrState) { + t.Fatalf("Token after Close: %v, want ErrState", err) + } +} + +func TestCredentialsPrintNoKey(t *testing.T) { + for _, c := range []Credentials{AutoCredentials(), newTestCredentials(testClientID, NewClientKey([]byte(testClientKey)), staticToken("t"))} { + for _, verb := range []string{"%v", "%+v", "%s"} { + if out := fmt.Sprintf(verb, c); strings.Contains(out, testClientKey[:16]) || !strings.HasPrefix(out, "stackencrypt.") { + t.Errorf("%s: %q", verb, out) + } + } + } +} + +type credentialsFunc func(context.Context, resolveOptions) (*resolvedCredentials, error) + +func (f credentialsFunc) resolve(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + return f(ctx, opts) +} + +func ptr(s string) *string { return &s } + +// NewCredentials takes its token only from a stackauth strategy: a nil one +// is refused host-side, before any guest is read, and the key is consumed +// all the same — the credentials are spent, as on any refused config. +func TestNewCredentialsRefusesANilStrategy(t *testing.T) { + key := NewClientKey([]byte(testClientKey)) + creds := NewCredentials(testClientID, key, nil) + _, err := NewClient(context.Background(), WithCredentials(creds)) + if !errors.Is(err, ErrEncoding) || !strings.Contains(err.Error(), "strategy") { + t.Fatalf("NewClient: %v, want ErrEncoding naming the strategy", err) + } + if !key.IsZero() { + t.Error("the key still holds material after NewClient refused a nil strategy") + } + if !creds.(*explicitCredentials).consumed.Load() { + t.Error("the credentials were not marked consumed") + } +} + +// NewCredentials reports the memory lock of the store the caller opened its +// strategy from, as AutoCredentials reports its profile's: the store's own +// answer, asked live. +func TestNewCredentialsReportsTheStrategysMemoryLock(t *testing.T) { + authGuestOrSkip(t) + ctx := context.Background() + // Best effort, stackauth's default: the store opens whatever the lock. + store, err := stackauth.OpenWithoutProfile(ctx) + if err != nil { + t.Fatal(err) + } + defer store.Close() + strategy, err := store.AccessKey(ctx, testCRN, testAccessKey, stackauth.WithAuthBaseURL("https://cts.example.com")) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + resolved, err := NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), strategy).resolve(ctx, resolveOptions{Transport: http.DefaultTransport}) + if err != nil { + t.Fatal(err) + } + resolved.ClientKey.Wipe() + if resolved.MemoryLockError == nil { + t.Fatal("MemoryLockError is not set: the strategy's store's lock state is not reported") + } + if got, want := resolved.MemoryLockError(), store.MemoryLockError(); fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("MemoryLockError() = %v, want the store's %v", got, want) + } +} + +// WithRequireLockedMemory covers the credential guest whatever the +// credentials: memory the credentials report unlocked is refused with +// ErrMemoryLock before the crypto guest is instantiated or a request made, +// the key is consumed and what the credentials hold is released. Under +// best effort the same report lets the client be made. The refusal is +// forced through the credentials' report, over a store opened best effort: +// the real refusal, under RLIMIT_MEMLOCK, is in +// TestRequireLockedMemoryRefusesACallerStoreUnlocked. +func TestRequireLockedMemoryRefusesUnlockedCredentials(t *testing.T) { + authGuestOrSkip(t) + forced := guest.MemoryLockError(errors.New("RLIMIT_MEMLOCK refused the credential guest")) + for name, base := range map[string]func(t *testing.T) Credentials{ + "NewCredentials": func(t *testing.T) Credentials { + cleanEnv(t, t.TempDir()) + auth := newAuthServer(t) + ctx := context.Background() + // Best effort, stackauth's default. + store, err := stackauth.OpenWithoutProfile(ctx) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + strategy, err := store.AccessKey(ctx, testCRN, testAccessKey, stackauth.WithAuthBaseURL(auth.URL)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = strategy.Close() }) + return NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), strategy) + }, + "AutoCredentials": func(t *testing.T) Credentials { + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + return AutoCredentials() + }, + } { + t.Run(name, func(t *testing.T) { + stub := newStub(t, http.StatusUnauthorized, "", "nope") + forcedCreds := func(t *testing.T) (Credentials, **resolvedCredentials) { + creds := base(t) + var got *resolvedCredentials + return credentialsFunc(func(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + r, err := creds.resolve(ctx, opts) + if err != nil { + return r, err + } + if r.MemoryLockError == nil { + t.Error("the credentials report no memory lock state") + } + r.MemoryLockError = func() error { return forced } + got = r + return r, nil + }), &got + } + creds, resolved := forcedCreds(t) + // No crypto guest is needed: the refusal precedes it. + _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL), WithGuest(wasiProbe), WithRequireLockedMemory()) + if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "credential guest") { + t.Fatalf("NewClient under WithRequireLockedMemory: %v, want ErrMemoryLock naming the credential guest", err) + } + if len(stub.requests) != 0 { + t.Errorf("a request was made for refused credentials: %+v", stub.requests) + } + if *resolved == nil { + // A host that cannot lock the credential guest at all (a + // 32-bit address space cannot reserve it) refuses it while + // resolving, before any key or token exists: the refusal + // above is that one, and there is nothing to have released. + t.Logf("the host refused the credential guest itself: %v", err) + return + } + if !(*resolved).ClientKey.IsZero() { + t.Error("the key still holds material after the credentials were refused") + } + if (*resolved).Close != nil { + if _, err := (*resolved).Token.Token(context.Background()); !errors.Is(err, stackauth.ErrState) { + t.Errorf("the token source after the refusal: %v, want it released (ErrState)", err) + } + } + + guestOrSkip(t) + creds, _ = forcedCreds(t) + if _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL)); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient under best effort: %v, want ErrUnauthorized from the stub, the report not refused", err) + } + }) + } +} + +// WithRequireLockedMemory lets credentials through whose memory report is +// nil: the report is asked, and NewClient goes on past it. The outcome +// after that depends on whether this host can lock the crypto guest, so it +// is judged by the credentials' refusal being absent, not by success. +func TestRequireLockedMemoryAcceptsLockedCredentials(t *testing.T) { + stub := newStub(t, http.StatusUnauthorized, "", "nope") + var asked atomic.Int32 + creds := credentialsFunc(func(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + r, err := testCredentials(staticToken("stub-token")).resolve(ctx, opts) + if err != nil { + return r, err + } + r.MemoryLockError = func() error { + asked.Add(1) + return nil + } + return r, nil + }) + _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL), WithGuest(wasiProbe), WithRequireLockedMemory()) + if asked.Load() == 0 { + t.Fatal("the credentials' memory report was not asked") + } + if err != nil && strings.Contains(err.Error(), "the credentials' memory") { + t.Fatalf("NewClient refused credentials reporting locked memory: %v", err) + } +} + +// The strategy given to NewCredentials is the caller's: the credentials +// hold nothing to close, and a client — here one whose init failed — leaves +// the strategy open. +func TestNewCredentialsLeavesTheStrategyToTheCaller(t *testing.T) { + guestOrSkip(t) + authGuestOrSkip(t) + ctx := context.Background() + store, err := stackauth.OpenWithoutProfile(ctx) + if err != nil { + t.Fatal(err) + } + defer store.Close() + cts := newStub(t, http.StatusUnauthorized, "", "nope") + strategy, err := store.AccessKey(ctx, testCRN, "CSAKtest.key", stackauth.WithAuthBaseURL(cts.URL)) + if err != nil { + t.Fatal(err) + } + creds := NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), strategy) + resolved, err := creds.resolve(ctx, resolveOptions{Transport: http.DefaultTransport}) + if err != nil { + t.Fatal(err) + } + if resolved.Token != tokenSource(strategy) || resolved.Close != nil { + t.Fatalf("resolved = %+v, want the strategy as the token source and no Close", resolved) + } + resolved.ClientKey.Wipe() + + zerokms := newStub(t, http.StatusOK, "application/json", "{}") + creds = NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), strategy) + if _, err := NewClient(ctx, WithCredentials(creds), withZeroKMSURL(zerokms.URL)); err == nil { + t.Fatal("NewClient succeeded with a token CTS refused") + } + // Still open: asked again, it goes back to CTS rather than failing + // with ErrState. + before := len(cts.requests) + if _, err := strategy.Token(ctx); errors.Is(err, stackauth.ErrState) { + t.Fatalf("the strategy after a failed NewClient: %v, want it still open", err) + } + if len(cts.requests) == before { + t.Error("the strategy made no request after NewClient: it was closed") + } +} diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go new file mode 100644 index 000000000..3bc5dd0af --- /dev/null +++ b/languages/golang/stackencrypt/doc.go @@ -0,0 +1,148 @@ +// Package stackencrypt is the Go binding of stack-encrypt: ZeroKMS-backed +// field-level encryption with searchable index terms, running the Rust +// crate unmodified inside a WASI guest under wazero (CGO_ENABLED=0). +// +// # Shape +// +// A [Client] is one wasm instance and one ZeroKMS client: [NewClient] +// resolves the client's [Credentials], instantiates the embedded guest, +// hands it the client key once — the [ClientKey] the credentials resolved +// to is consumed and wiped, whatever the outcome — and loads the client's +// default keyset. It takes functional options ([ClientOption]), every one +// with a default, so NewClient(ctx) alone is a working client. Every keyset +// the client uses after that is selected per call through a +// [KeysetSelector] and loaded on first use by the guest's own bounded +// cache; nothing the host could allocate, alias or free crosses the +// boundary. [Client.Close] runs the guest's shutdown so the client key and +// every loaded index key are wiped before the instance is freed — closing a +// wasm instance runs no Rust destructors on its own. Close is hygiene, not +// the security story: see Memory below. +// +// A [Cipher] is the client bound to one keyset ([Client.Keyset] and +// [Client.DefaultKeyset], the Rust crate's StackCipher::keyset and +// default_keyset): it seals values, derives terms and encrypts records +// under that keyset, and opens only that keyset's ciphertexts. The +// [Client] itself opens ciphertexts from any keyset ([Client.Decrypt] and +// friends), fetching batched key retrievals per keyset the leaves were +// sealed under. +// +// # Values +// +// Values cross the boundary in vitaminc's FFI codec ([vcffi]) and are +// modelled as Go natives ([vcvalue]): builtins, slices, maps and structs +// seal by reflection, [vcvalue.Plain] marks a passthrough field, and a +// type implementing [vcffi.Encryptable] drives its own encoding. A +// ciphertext is the same dynamic shape with [Sealed] leaves — a distinct +// type from vcvalue's, because a stack-encrypt leaf is not a vitaminc leaf +// and must never scan or marshal where one belongs. The leaf bytes are the +// frozen stack-encrypt storage format; the transport encoding is not. +// +// # Records and terms +// +// [Cipher.EncryptRecords] is the runtime form of the Rust derive: a struct's +// `stash` tags say, per field, which context to bind and which index terms +// to produce, and one call seals every row of a slice from batched key +// requests. The same plan is a value ([Plan]): [PlanFromTags] is what the +// tags parse to, [NewPlan] builds one for a struct that cannot carry tags +// (generated code), and [WithPlan] runs a record call under it. The plan +// subpackage derives one from a field's schema facts through a policy +// written in Go. Key +// requests are batched 500 keys at a time, in both directions: one request +// for any ordinary value or batch, one more per 500 sealed leaves beyond +// that. Terms ([EqualityTerm], [MatchTerm], [OreTerm], [OpeTerm]) are +// byte-equal to the ones the Rust crate derives, so a probe from +// [Cipher.Term] compares against a stored term from any language. +// [Cipher.Term] takes a context and returns an error from day one: term +// derivation may be a ZeroKMS round trip. +// +// # Transport and auth +// +// The guest imports exactly two host functions: an HTTP send, served by any +// [net/http.RoundTripper], and a bearer-token fetch, served by the +// credentials' stackauth strategy. What crosses per ZeroKMS call is what would cross TLS +// anyway; derived key material never leaves the guest. Under +// [AutoCredentials] and [OIDCFederation] the same RoundTripper also carries +// the authentication requests to CTS, so one scoped to the ZeroKMS host +// alone is not enough. Under [NewCredentials] those requests go through the +// store the caller opened the strategy from. +// +// # Credentials +// +// A [Credentials] supplies the client id, the client key and the stackauth +// strategy the token comes from, and NewClient resolves it host-side: the crypto guest is never +// given the environment or a filesystem to find them in. The default, +// [AutoCredentials], mirrors the Rust client — the environment first +// (CS_CLIENT_ACCESS_KEY with CS_WORKSPACE_CRN for the token, CS_CLIENT_ID +// with CS_CLIENT_KEY for the key), then the developer profile, which it +// reads through stackauth's credential guest, where the token strategies +// also run. CS_ZEROKMS_HOST (or CS_VITUR_HOST) pins the endpoint whatever +// the credentials. [NewCredentials] takes a client id, a client key and a +// strategy explicitly, and [OIDCFederation] mints the token from an +// identity provider's. Pass one with [WithCredentials]. Those three are +// the only kinds of Credentials, and none takes a raw token: a token is +// always a stackauth strategy's, since a raw one cannot be refreshed when +// it expires and would bypass the cross-process lock a device-session +// refresh holds with the CLI. +// Credentials that cannot be resolved fail NewClient with +// [ErrNoCredentials]; credentials that resolve but do not work fail it too, +// at the one ZeroKMS round trip it makes. +// +// # Host runtime +// +// The guest also imports WASI random_get and clock_time_get, and the +// cipher's security rests on the first: ZeroKMS IVs and AEAD nonces are +// drawn from it. wazero's defaults for both are deterministic, so every +// instance is configured with the process CSPRNG ([crypto/rand.Reader]) +// and the system clocks. An embedder that instantiates the guest module +// under its own wazero configuration must do the same. +// +// # Memory +// +// Every key the guest holds — the client key, each loaded index key, each +// data key for the length of a call — lives in the guest's linear memory, +// and the package supplies that memory itself rather than taking wazero's +// default Go slice. It is reserved once at the module's declared maximum, +// so growth never copies it (wazero's default grows with append, which +// would leave an unwiped copy of every key to the garbage collector); +// locked in RAM (mlock, VirtualLock) so it is never written to swap; +// excluded from core dumps on Linux (MADV_DONTDUMP); and wiped before it +// is released, on every release path. +// +// That is deliberately done at allocation, where the caller cannot get it +// wrong, and not at exit, where they cannot be relied on: no deferred +// [Client.Close] runs on SIGTERM without a handler, SIGKILL, the OOM +// killer, a panic on another goroutine or os.Exit, and the package installs +// no signal handler — that is the application's to own, and covers only +// the first of those anyway. The kernel zeroes a dead process's pages +// before anyone else sees them; the lock and the dump exclusion close the +// two places a copy could otherwise outlive the process. +// +// The lock is best effort: RLIMIT_MEMLOCK defaults to 64 KiB on many +// Linux hosts and the guest is larger, so it is commonly refused, and a +// client then works on with memory that may be swapped — which is all +// that is lost, and nothing on a host without swap. [Client.MemoryLocked] +// reports the outcome and [Client.MemoryLockError] the reason, naming the +// limit to raise (ulimit -l, a systemd LimitMEMLOCK=, a pod's +// securityContext). [WithRequireLockedMemory] turns a refusal into a +// [NewClient] failure with [ErrMemoryLock], for deployments that would +// rather not start than run unlocked; it also refuses any later growth of +// the guest's memory that cannot be locked, so the limit granted must +// leave the guest room to grow: a refused growth fails the call with +// [ErrMemoryLock], and closes the client when the growth was the guest's +// own allocation rather than a host-staged buffer. The report and the +// policy cover the credential guest as well; with [NewCredentials] that is +// the caller's stackauth store, which NewClient refuses under the policy +// when it is unlocked, and which should be opened with +// stackauth.RequireLockedMemory to stay locked (see +// [WithRequireLockedMemory]). A Client prints its memory state +// ([Client.String]) and logs it ([Client.LogValue]). An embedder running +// the guest under its own wazero configuration gets none of this unless +// it supplies an allocator of its own. +// +// Between calls the guest holds the client key and its keyset cache (each +// keyset's index key) and nothing else: data keys are per-call values in +// the guest's Rust code, wiped by their ZeroizeOnDrop when the export +// returns, and every buffer staged for a call is wiped by se_dealloc +// before the call's result is returned. The residency tests pin the +// second; the first is the Rust crate's own guarantee. +package stackencrypt diff --git a/languages/golang/stackencrypt/errors.go b/languages/golang/stackencrypt/errors.go new file mode 100644 index 000000000..212c6bb2f --- /dev/null +++ b/languages/golang/stackencrypt/errors.go @@ -0,0 +1,61 @@ +package stackencrypt + +import "github.com/cipherstash/stack/languages/golang/internal/guest" + +// Failure kinds surfaced across the boundary. The guest reports a status +// code and nothing else, so these are the whole vocabulary: they separate a +// tampered ciphertext from a bad token from a malformed input, and reveal +// nothing about plaintext or key material. +// +// They are the sentinels every guest package shares (one status table for +// every guest, decoded once), exposed here under this package's names: an +// error from stackauth is the same value, so errors.Is holds across the +// two. +var ( + // ErrAuthentication is an AEAD open failure: a tampered ciphertext, a + // wrong element derivation, or a wrong AAD that reached the AEAD. Against + // ZeroKMS a wrong AAD is usually refused earlier as ErrForbidden, because + // every data key is bound to its context. + ErrAuthentication = guest.ErrAuthentication + // ErrEncoding is a malformed input: a value, ciphertext, plan, context, + // selector or config the guest refused before any cryptography. + ErrEncoding = guest.ErrEncoding + // ErrState is a call on a client that has been closed. + ErrState = guest.ErrState + // ErrInternal is a guest panic or any other unexpected guest failure. + ErrInternal = guest.ErrInternal + // ErrUnauthorized is ZeroKMS refusing the bearer token (HTTP 401): the + // token is invalid, expired, or for another workspace. + ErrUnauthorized = guest.ErrUnauthorized + // ErrForbidden is ZeroKMS refusing the request (HTTP 403): the token is + // valid but not permitted, or a data key's bound context did not match + // the one presented — the production form of a wrong-AAD open. + ErrForbidden = guest.ErrForbidden + // ErrNotFound is ZeroKMS reporting a missing resource (HTTP 404): an + // unknown keyset name or id, or a data key that does not exist. + ErrNotFound = guest.ErrNotFound + // ErrConflict is ZeroKMS reporting a resource conflict (HTTP 409). + ErrConflict = guest.ErrConflict + // ErrTransport is a failure to reach ZeroKMS or to read its response: + // the transport returned an error, or the endpoint could not be resolved. + ErrTransport = guest.ErrTransport + // ErrKMS is any other ZeroKMS failure: an unparseable response, invalid + // key material, or an unclassified server error. + ErrKMS = guest.ErrKMS + // ErrTerm is a term derivation the scheme could not perform for the + // given input, such as match text that yields no tokens. + ErrTerm = guest.ErrTerm + // ErrForeignKeyset is a keyset-bound Cipher refusing a ciphertext sealed + // under another keyset, before any key is retrieved. Open it through the + // Client, which is not bound to one keyset. + ErrForeignKeyset = guest.ErrForeignKeyset + // ErrMemoryLock is guest memory that could not be locked in RAM (or, + // on Linux, excluded from core dumps). NewClient returns it when + // WithRequireLockedMemory is given, and so does any later call under + // that setting whose growth of the guest's memory could not be locked; + // otherwise Client.MemoryLockError reports it and the client works on + // with unlocked memory. The wrapped error names the limit that refused + // the lock and the size the guest holds: on Linux, RLIMIT_MEMLOCK + // (ulimit -l, a systemd LimitMEMLOCK=, or a pod's securityContext). + ErrMemoryLock = guest.ErrMemoryLock +) diff --git a/languages/golang/stackencrypt/example/README.md b/languages/golang/stackencrypt/example/README.md new file mode 100644 index 000000000..aa6486e67 --- /dev/null +++ b/languages/golang/stackencrypt/example/README.md @@ -0,0 +1,72 @@ +# stack-encrypt Go example + +A runnable tour of the Go binding against real ZeroKMS: seal a value, seal a +record with its index terms, probe those terms with a query, open both again. +It prints what crossed the boundary at each step, including the steps that +are meant to fail. + +## Running it + +```bash +stash auth login # once; the example reads ~/.cipherstash +mise run go:stackencrypt:example # builds both guests, then runs +``` + +Or, if you would rather drive it yourself: + +```bash +mise run wasm:guest:build wasm:auth-guest:build +cd bindings/go && go run ./stackencrypt/example +``` + +The guest builds are not optional. This package embeds +`wasm/stack_encrypt_guest.wasm` and `stackauth` embeds +`wasm/stack_auth_guest.wasm`; both are gitignored, so a fresh checkout has +no guests and `NewClient` / `Resolve` fail until they are built — and the Go +side will not notice a stale one, so rebuild after any change under either +`guest/src/`. + +## What it shows + +| | | +|---|---| +| **A value** | An arbitrary map sealed under a caller-chosen AAD. A `vcvalue.Plain` field rides alongside in the clear. Opening under the wrong AAD is refused — at the *key retrieval*, not the AEAD, because every data key is bound to its context. | +| **A record** | A struct's `stash` tags drive a plan: each field sealed under its own context, with the index terms it asked for. Three rows, one batched ZeroKMS request. | +| **A query** | An equality term derived from the value being searched for, matched against the stored terms. The same value under another field's context matches nothing — that is what stops a hit in one column being a hit in another. | +| **Order** | ORE terms sorted, recovering the plaintext order without the plaintext. | + +## Credentials + +The example calls `NewClient(ctx)` with no options, so it resolves its +credentials with `stackencrypt.AutoCredentials`, which is what any +application gets by default. It looks in the environment first and then in +the developer profile, in the order the Rust client uses: + +- **The token.** If `CS_CLIENT_ACCESS_KEY` is set (with `CS_WORKSPACE_CRN`), + the access key is exchanged for a token. Otherwise the current workspace's + stored device session is used. Both run in `stackauth`'s credential guest. +- **The client key.** `CS_CLIENT_ID` and `CS_CLIENT_KEY` if both are set, + otherwise the current workspace's `secretkey.json`. +- **The endpoint.** `CS_ZEROKMS_HOST` if set, otherwise the token's + `services` claim. + +`CS_CONFIG_PATH` overrides the profile directory, and `CS_CTS_HOST` +overrides the authentication endpoint. + +Nothing in the example spells out the profile's layout. The `stack-profile` +crate reads it inside the credential guest, so the example cannot drift +from it. The crypto guest still sees no environment and no filesystem: +credentials are resolved host-side. + +The device session is **asked on every request and refreshes itself**. A +profile token lasts 45 minutes, so a pinned token would give you a program +that works for a while and then stops; that is why the client takes tokens +only from `stackauth` strategies and has no way to pass a raw one. The refresh takes the +same cross-process lock as the `stash` CLI. The IdP rotates refresh tokens +and detects replay, so two processes sharing `~/.cipherstash` that both +exchanged the same refresh token would get the whole chain revoked; the lock +prevents that. + +To supply the credentials yourself instead, from a secrets manager and with +no `CS_*` variables or profile, see [`explicit/`](explicit/), which uses +`stackencrypt.NewCredentials`. diff --git a/languages/golang/stackencrypt/example/explicit/README.md b/languages/golang/stackencrypt/example/explicit/README.md new file mode 100644 index 000000000..4048119ed --- /dev/null +++ b/languages/golang/stackencrypt/example/explicit/README.md @@ -0,0 +1,53 @@ +# Explicit credentials + +A runnable example of `stackencrypt.NewCredentials`: the application +supplies every credential itself, and nothing is read from `CS_*` variables +or the developer profile. Use this shape when the client key lives in a +secrets manager, when one process talks to more than one workspace, or when +the environment is not yours to set. `../` shows the default, +`AutoCredentials`. + +## Running it + +The secrets come from a directory holding two files, the way a Kubernetes or +Docker secret is mounted: + +| File | Contents | +|---|---| +| `client-key` | The client key: the `CS_CLIENT_KEY` hex form, or the base64 in `secretkey.json`. | +| `access-key` | An access key (`CSAK…`) for the workspace. | + +```bash +mise run go:stackencrypt:example:explicit -- \ + -secrets-dir /run/secrets \ + -client-id <client id> \ + -workspace-crn <workspace CRN> +``` + +Or build both guests and run it yourself from `bindings/go`: + +```bash +mise run wasm:guest:build wasm:auth-guest:build +cd bindings/go && go run ./stackencrypt/example/explicit -secrets-dir ... -client-id ... -workspace-crn ... +``` + +Optional flags: `-cts-host` pins the authentication endpoint (default: from +the workspace CRN), and `-require-locked-memory` refuses to run on memory +that cannot be locked in RAM. The ZeroKMS endpoint comes from the token. + +## What it shows + +- **Where secrets come from.** `fileSecrets` reads mounted files. Replace it + with your secrets manager's client; the rest does not change. The client + key goes straight into `NewClientKey`, which takes ownership of the bytes, + and `NewClient` wipes them. +- **Who owns what.** The application opens the credential guest + (`stackauth.OpenWithoutProfile`) and the access-key strategy, and closes + them after the client. The client asks the strategy for a token on every + request, and the strategy re-exchanges the access key as tokens expire. +- **Locked memory.** With `-require-locked-memory` the credential guest is + opened with `stackauth.RequireLockedMemory()` and the client with + `WithRequireLockedMemory()`, so both guests stay locked. The client's + printed memory state covers both. +- **A key is for one client.** Passing the same credentials to a second + `NewClient` is refused with `ErrCredentialsConsumed`, before any request. diff --git a/languages/golang/stackencrypt/example/explicit/main.go b/languages/golang/stackencrypt/example/explicit/main.go new file mode 100644 index 000000000..6dee61e4c --- /dev/null +++ b/languages/golang/stackencrypt/example/explicit/main.go @@ -0,0 +1,166 @@ +// Command explicit connects the stack-encrypt Go binding to real ZeroKMS +// with credentials the application supplies itself: the client key and the +// access key come from a secrets source, the client id and workspace from +// configuration, and nothing is read from CS_* variables or the developer +// profile. It is the NewCredentials counterpart of ../example, which uses +// AutoCredentials. +// +// mise run wasm:guest:build wasm:auth-guest:build # both embedded guests +// go run ./stackencrypt/example/explicit \ +// -secrets-dir /run/secrets \ +// -client-id 6a70bd18-99ac-4650-b104-37eec3a15b09 \ +// -workspace-crn crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY +// +// The secrets directory holds two files, client-key and access-key, the way +// a Kubernetes or Docker secret is mounted. See README.md. +package main + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/cipherstash/stack/languages/golang/stackauth" + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +type user struct { + ID int64 `stash:"-"` + Email string `stash:"context=users/email,index=eq"` +} + +type config struct { + secretsDir string + clientID string + workspaceCRN string + ctsHost string + requireLockedMemory bool +} + +func main() { + var cfg config + flag.StringVar(&cfg.secretsDir, "secrets-dir", "/run/secrets", "directory holding the client-key and access-key secrets") + flag.StringVar(&cfg.clientID, "client-id", "", "the ZeroKMS client id (not a secret)") + flag.StringVar(&cfg.workspaceCRN, "workspace-crn", "", "the workspace the access key belongs to") + flag.StringVar(&cfg.ctsHost, "cts-host", "", "pin the authentication endpoint (default: from the workspace CRN)") + flag.BoolVar(&cfg.requireLockedMemory, "require-locked-memory", false, "refuse to run on memory that cannot be locked in RAM") + flag.Parse() + if cfg.clientID == "" || cfg.workspaceCRN == "" { + fmt.Fprintln(os.Stderr, "usage: explicit -client-id ID -workspace-crn CRN [-secrets-dir DIR]") + os.Exit(2) + } + if err := run(context.Background(), cfg, fileSecrets{dir: cfg.secretsDir}); err != nil { + fmt.Fprintf(os.Stderr, "\nerror: %v\n", err) + os.Exit(1) + } +} + +// secrets is where the application keeps what must not be in its +// configuration. This example reads mounted files; an application would +// call its secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager) +// here instead. The bytes returned are the caller's to wipe or hand on. +type secrets interface { + Get(ctx context.Context, name string) ([]byte, error) +} + +type fileSecrets struct{ dir string } + +func (s fileSecrets) Get(_ context.Context, name string) ([]byte, error) { + b, err := os.ReadFile(filepath.Join(s.dir, name)) //nolint:gosec // example reads the operator's mounted secrets + if err != nil { + return nil, fmt.Errorf("reading secret %q: %w", name, err) + } + // A mounted secret often ends in a newline. Trimming returns a + // sub-slice of the same array, so nothing is copied. + return bytes.TrimSpace(b), nil +} + +func run(ctx context.Context, cfg config, secrets secrets) error { + // The credential guest the token strategy runs in. The caller opens + // it, so the caller chooses its memory policy: under + // -require-locked-memory it is locked from the start and stays locked + // as it grows. NewClient checks it either way, below. + var storeOpts []stackauth.Option + if cfg.requireLockedMemory { + storeOpts = append(storeOpts, stackauth.RequireLockedMemory()) + } + // No profile: this application's credentials are all explicit. + store, err := stackauth.OpenWithoutProfile(ctx, storeOpts...) + if err != nil { + return fmt.Errorf("opening the credential guest: %w", err) + } + // Deferred calls run last-first: the client closes before the strategy + // it asks for tokens, and the strategy before the store it lives in. + defer store.Close() + + accessKey, err := secrets.Get(ctx, "access-key") + if err != nil { + return err + } + var strategyOpts []stackauth.StrategyOption + if cfg.ctsHost != "" { + strategyOpts = append(strategyOpts, stackauth.WithAuthBaseURL(cfg.ctsHost)) + } + // The access key crosses as a string, which Go cannot wipe; the bytes + // it was read into can be. + strategy, err := store.AccessKey(ctx, cfg.workspaceCRN, string(accessKey), strategyOpts...) + clear(accessKey) + if err != nil { + return fmt.Errorf("access-key strategy: %w", err) + } + defer strategy.Close() + + keyMaterial, err := secrets.Get(ctx, "client-key") + if err != nil { + return err + } + // NewClientKey takes ownership of the bytes; NewClient wipes them. + creds := stackencrypt.NewCredentials(cfg.clientID, stackencrypt.NewClientKey(keyMaterial), strategy) + + opts := []stackencrypt.ClientOption{stackencrypt.WithCredentials(creds)} + if cfg.requireLockedMemory { + opts = append(opts, stackencrypt.WithRequireLockedMemory()) + } + client, err := stackencrypt.NewClient(ctx, opts...) + if err != nil { + return fmt.Errorf("connecting to ZeroKMS: %w", err) + } + defer client.Close() + // The memory state covers both guests: the crypto guest holding the + // key, and the credential guest the token strategy runs in. + fmt.Printf("connected (%v)\n", client) + + // A key is for one client. These credentials are spent, and a second + // client needs a new key; nothing was sent to find that out. + if _, err := stackencrypt.NewClient(ctx, stackencrypt.WithCredentials(creds)); errors.Is(err, stackencrypt.ErrCredentialsConsumed) { + fmt.Println("reusing the credentials is refused: the key was consumed by the first client") + } else { + return fmt.Errorf("reusing the credentials: got %v, want ErrCredentialsConsumed", err) + } + + cipher := client.DefaultKeyset() + rows := []user{{ID: 1, Email: "alice@example.com"}, {ID: 2, Email: "bob@example.com"}} + records, err := cipher.EncryptRecords(ctx, rows) + if err != nil { + return fmt.Errorf("encrypting records: %w", err) + } + probe, err := cipher.Term(ctx, "bob@example.com", stackencrypt.MustContext("users/email"), stackencrypt.Equality) + if err != nil { + return fmt.Errorf("deriving a probe: %w", err) + } + for i, r := range records { + if probe.(stackencrypt.EqualityTerm).Equal(r["Email"].Equality) { + fmt.Printf("sealed %d rows; the probe for bob@example.com matches row %d\n", len(records), i) + } + } + var back []user + if err := cipher.DecryptRecords(ctx, records, &back); err != nil { + return fmt.Errorf("decrypting records: %w", err) + } + fmt.Printf("opened %v\n", back) + return nil +} diff --git a/languages/golang/stackencrypt/example/main.go b/languages/golang/stackencrypt/example/main.go new file mode 100644 index 000000000..88db70002 --- /dev/null +++ b/languages/golang/stackencrypt/example/main.go @@ -0,0 +1,209 @@ +// Command example exercises the stack-encrypt Go binding against real +// ZeroKMS, using the credentials `stash auth login` leaves in the developer +// profile (or the CS_* environment variables, which win). +// +// stash auth login +// mise run wasm:guest:build wasm:auth-guest:build # both embedded guests +// go run ./stackencrypt/example # from bindings/go +// +// It walks the four things the binding does — seal a value, seal a record +// with its index terms, probe those terms with a query, and open both again +// — and prints what crossed the boundary at each step. +package main + +import ( + "context" + "fmt" + "os" + "sort" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// A record type. The `stash` tag is the Go stand-in for Rust's +// `#[derive(EncryptFrom)]`: `context=` is the field's own encryption +// context, `index=` the terms to derive beside the ciphertext. +type user struct { + ID int64 `stash:"-"` + Email string `stash:"context=users/email,index=eq;match"` + Age uint32 `stash:"context=users/age,index=eq;ore"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "\nerror: %v\n", err) + os.Exit(1) + } +} + +func run() error { + ctx := context.Background() + // No options: credentials from AutoCredentials, which is the + // environment first (CS_CLIENT_ACCESS_KEY + CS_WORKSPACE_CRN, CS_CLIENT_ID + // + CS_CLIENT_KEY), then the developer profile `stash auth login` writes, + // read through stackauth's credential guest. The token is a refreshing + // device session there, asked on every request, so a long run outlives + // one token. The ZeroKMS endpoint: CS_ZEROKMS_HOST if set, else the token's + // services claim. + client, err := stackencrypt.NewClient(ctx) + if err != nil { + return fmt.Errorf("connecting to ZeroKMS: %w", err) + } + // Close runs the guest's own wipe of the client key and every loaded + // index key, and releases the credential guest. The memory's protection + // does not wait on it (see the package docs); this is ordinary resource + // hygiene. + defer client.Close() + fmt.Printf("connected (%v)\n", client) + + cipher := client.DefaultKeyset() + keysetID, err := cipher.KeysetID(ctx) + if err != nil { + return err + } + fmt.Printf("default keyset %s\n\n", keysetID) + + if err := values(ctx, cipher); err != nil { + return err + } + records, err := recordsAndTerms(ctx, cipher) + if err != nil { + return err + } + return ordering(records) +} + +// A whole value, sealed under an AAD of the caller's choosing. The shape of +// the ciphertext mirrors the plaintext, and a field marked Plain rides +// alongside it in the clear. +func values(ctx context.Context, cipher *stackencrypt.Cipher) error { + section("a value") + + aad := []byte("users/v1") + in := map[string]any{ + "name": "alice", + "age": uint32(34), + "note": vcvalue.Plain{V: "not secret"}, + } + fmt.Printf(" plaintext %v\n", in) + + sealed, err := cipher.Encrypt(ctx, in, aad) + if err != nil { + return fmt.Errorf("encrypting a value: %w", err) + } + for name, node := range sealed.(map[string]any) { + if leaf, ok := node.(stackencrypt.Sealed); ok { + fmt.Printf(" %-11s %d bytes of ciphertext\n", name, len(leaf)) + } else { + fmt.Printf(" %-11s %v (passthrough — in the clear, and unauthenticated)\n", name, node) + } + } + + opened, err := cipher.Decrypt(ctx, sealed, aad) + if err != nil { + return fmt.Errorf("decrypting a value: %w", err) + } + fmt.Printf(" opened %v\n", opened) + + // The AAD is bound into the key as well as the ciphertext, so the wrong + // one does not open the value — it is refused, not silently wrong. + if _, err := cipher.Decrypt(ctx, sealed, []byte("some other context")); err == nil { + return fmt.Errorf("a value opened under an AAD it was not sealed under") + } else { + fmt.Printf(" wrong AAD refused: %v\n", err) + } + return nil +} + +// A record: every field sealed under its own context, with the index terms +// its tag asked for, and all of it from one batched ZeroKMS request. +func recordsAndTerms(ctx context.Context, cipher *stackencrypt.Cipher) ([]stackencrypt.EncryptedRecord, error) { + section("records, and the terms that index them") + + users := []user{ + {ID: 1, Email: "alice@example.com", Age: 34}, + {ID: 2, Email: "bob@example.com", Age: 29}, + {ID: 3, Email: "carol@example.com", Age: 41}, + } + records, err := cipher.EncryptRecords(ctx, users) + if err != nil { + return nil, fmt.Errorf("encrypting records: %w", err) + } + fmt.Printf(" %d rows sealed in one batched key request\n", len(records)) + for i, r := range records { + fmt.Printf(" row %d Email: %d-byte ciphertext, eq %x…, match %d positions\n", + i, len(r["Email"].Ciphertext.(stackencrypt.Sealed)), r["Email"].Equality[:6], countPositions(r["Email"].Match)) + fmt.Printf(" Age: %d-byte ciphertext, eq %x…, ore %d bytes\n", + len(r["Age"].Ciphertext.(stackencrypt.Sealed)), r["Age"].Equality[:6], len(r["Age"].Ore)) + } + + // A query probe: the same derivation as the stored term, from the value + // being searched for. It never touches the ciphertext — matching is what + // the term is for. + fmt.Println() + probe, err := cipher.Term(ctx, "bob@example.com", stackencrypt.MustContext("users/email"), stackencrypt.Equality) + if err != nil { + return nil, fmt.Errorf("deriving a probe: %w", err) + } + for i, r := range records { + if probe.(stackencrypt.EqualityTerm).Equal(r["Email"].Equality) { + fmt.Printf(" probe for bob@example.com matches row %d\n", i) + } + } + + // A term is bound to its context. The same value under another field's + // context is a different term, which is what stops a match in one column + // from being a match in another. + wrong, err := cipher.Term(ctx, "bob@example.com", stackencrypt.MustContext("users/name"), stackencrypt.Equality) + if err != nil { + return nil, fmt.Errorf("deriving a probe: %w", err) + } + fmt.Printf(" the same value under users/name matches nothing: %t\n", + !wrong.(stackencrypt.EqualityTerm).Equal(records[1]["Email"].Equality)) + + var back []user + if err := cipher.DecryptRecords(ctx, records, &back); err != nil { + return nil, fmt.Errorf("decrypting records: %w", err) + } + fmt.Printf("\n opened %v\n", back) + fmt.Printf(" (ID is tagged `-`, so it never crossed the boundary and comes back zero)\n") + return records, nil +} + +// ORE terms compare in the plaintext's order without revealing it: sorting +// the rows by their Age term sorts them by age. +func ordering(records []stackencrypt.EncryptedRecord) error { + section("order, without the values") + + order := []int{0, 1, 2} + sort.Slice(order, func(i, j int) bool { + return records[order[i]]["Age"].Ore.Less(records[order[j]]["Age"].Ore) + }) + fmt.Printf(" rows sorted by their Age ORE terms: %v\n", order) + fmt.Printf(" (ages were 34, 29, 41 — so ascending age is row 1, 0, 2)\n") + return nil +} + +func countPositions(t stackencrypt.MatchTerm) int { + positions, err := t.Positions() + if err != nil { + return -1 + } + return len(positions) +} + +func section(title string) { + fmt.Printf("── %s %s\n", title, dashes(60-len(title))) +} + +func dashes(n int) string { + if n < 0 { + n = 0 + } + out := make([]byte, 0, n*3) + for range n { + out = append(out, "─"...) + } + return string(out) +} diff --git a/languages/golang/stackencrypt/export_test.go b/languages/golang/stackencrypt/export_test.go new file mode 100644 index 000000000..22b844ae5 --- /dev/null +++ b/languages/golang/stackencrypt/export_test.go @@ -0,0 +1,53 @@ +package stackencrypt + +import ( + "context" + "reflect" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" +) + +// The test-only ways to give a client a token. The public API takes tokens +// only from stackauth strategies; the tests that talk to httptest stubs +// need a fixed one, and get it here rather than through anything a caller +// could reach. + +// tokenFunc adapts a function to a tokenSource. +type tokenFunc func(ctx context.Context) (string, error) + +func (f tokenFunc) Token(ctx context.Context) (string, error) { return f(ctx) } + +// staticToken is a tokenSource that always returns token. +func staticToken(token string) tokenSource { + return tokenFunc(func(context.Context) (string, error) { return token, nil }) +} + +// newTestCredentials is NewCredentials with token in place of a strategy: +// the same type, so the same consume-on-refusal and no-Close semantics. +// A nil token is refused as NewCredentials refuses a nil strategy. +func newTestCredentials(clientID string, key *ClientKey, token tokenSource) Credentials { + return &explicitCredentials{clientID: clientID, key: key, token: token} +} + +// withZeroKMSURL points the client at a ZeroKMS stub. There is no public +// option for it: applications take the endpoint from the token, or from +// CS_ZEROKMS_HOST. +func withZeroKMSURL(url string) ClientOption { + return func(o *clientOptions) { o.zerokmsURL = url } +} + +// GuestPlanInput is the encoded plan object a record call over t sends the +// guest under p, each context extended by ext: what the external tests +// compare byte for byte. Test-only; not part of the package's API. +func GuestPlanInput(p Plan, t reflect.Type, ext ...any) ([]byte, error) { + o := applyOptions([]RecordOption{WithPlan(p), ExtendContext(ext...)}) + bound, err := planFor(t, o) + if err != nil { + return nil, err + } + obj, err := planValue(bound, o) + if err != nil { + return nil, err + } + return vcffi.Marshal(obj) +} diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go new file mode 100644 index 000000000..7c521331f --- /dev/null +++ b/languages/golang/stackencrypt/guest.go @@ -0,0 +1,205 @@ +package stackencrypt + +import ( + "context" + "crypto/rand" + "embed" + "errors" + "fmt" + "sync" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/experimental" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +// The guest module is a build artefact of the Rust crate in ./guest, +// copied here by `mise run wasm:guest:build`. It is embedded as a +// directory so the package compiles without it; NewClient reports its +// absence. +// +//go:embed wasm +var guestFS embed.FS + +const guestPath = "wasm/stack_encrypt_guest.wasm" + +// ErrGuestNotBuilt is returned by NewClient when no guest module is +// embedded and none was supplied with WithGuest. +var ErrGuestNotBuilt = errors.New("stackencrypt: guest module not built — run `mise run wasm:guest:build`") + +func embeddedGuest() ([]byte, error) { + wasm, err := guestFS.ReadFile(guestPath) + if err != nil { + return nil, ErrGuestNotBuilt + } + return wasm, nil +} + +// One shared compilation cache: only the first instantiation of a given +// module in the process compiles it. Every Client still owns its own +// runtime and instance. +var ( + cacheOnce sync.Once + sharedCache wazero.CompilationCache +) + +func compilationCache() wazero.CompilationCache { + cacheOnce.Do(func() { sharedCache = wazero.NewCompilationCache() }) + return sharedCache +} + +// instance is one instantiated guest with its exports resolved. It is +// the unsynchronised half of a Client; the Client serialises access. +type instance struct { + runtime wazero.Runtime + module api.Module + // mem supplied the module's linear memory (internal/guest) and reports + // on it. + mem *guest.Allocator + + exports guest.Exports + cipherInit, shutdown, keyset api.Function + encrypt, encryptElement api.Function + decrypt, decryptElement api.Function + term api.Function + encryptRecord, decryptRecord api.Function +} + +// guestModuleConfig is the module configuration every guest instance runs +// under. wazero's defaults are deterministic by design (see its +// RATIONALE.md): a WASI random_get backed by math/rand with a fixed seed, +// and clocks that start at a fixed epoch and advance 1ms per read. The +// guest's cipher draws ZeroKMS IVs and AEAD nonces through random_get, so +// the default would hand every instance the same nonce sequence; its +// keyset-name cache expires on clock_time_get, so the default would never +// let a name expire on wall time. Each override below is load-bearing and +// pinned by TestGuestModuleConfigHostSources. +func guestModuleConfig() wazero.ModuleConfig { + return wazero.NewModuleConfig(). + WithName("stack_encrypt_guest"). + // crypto/rand.Reader: the process CSPRNG, safe for concurrent use. + WithRandSource(rand.Reader). + WithSysNanotime(). + WithSysWalltime() +} + +// newInstance instantiates wasm with the transport as its host module and +// its linear memory from the guest packages' shared allocator. Under the +// strict policy, memory that cannot be locked fails instantiation with +// ErrMemoryLock. +func newInstance(ctx context.Context, wasm []byte, t *transport, policy guest.LockPolicy) (*instance, error) { + // WithCloseOnContextDone lets a caller's deadline or cancellation + // interrupt an in-flight guest call — which otherwise holds the Client's + // lock against every other user. An interrupted call closes the module, + // so the Client is done afterwards; the alternative is a wedged process. + config := wazero.NewRuntimeConfig(). + WithCompilationCache(compilationCache()). + WithCloseOnContextDone(true) + runtime := wazero.NewRuntimeWithConfig(ctx, config) + // The Must* form of this panics on any error, which is the wrong + // failure mode for a constructor in a library and would strand the + // runtime it was instantiating into. No error is reachable here today — + // the host module is fixed and the runtime is new and private, so there + // is nothing for it to collide with — so this is the total form of a + // call that does not currently fail, matching the host transport below. + if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + _ = runtime.Close(ctx) + return nil, fmt.Errorf("stackencrypt: instantiating WASI: %w", err) + } + if err := t.instantiate(ctx, runtime); err != nil { + _ = runtime.Close(ctx) + return nil, err + } + // The guest's linear memory comes from the guest packages' allocator, + // not wazero's default slice: reserved once, locked and non-dumpable + // where the platform allows, wiped on release. See internal/guest. + mem := guest.NewAllocator(policy) + // The guest is a reactor (cdylib): no _start. wazero runs _initialize + // when present, so guest code runs here too, and the memory must stay + // mapped until it returns, the same as around a call. Today nothing in + // _initialize re-enters the guest from Go, which is the only path that + // frees memory under a suspended guest; the bracket makes that a + // property of this code rather than of what the guest's constructors + // happen to call. See guest.Allocator.Free. + mem.Enter() + module, err := func() (api.Module, error) { + defer mem.Exit() + return runtime.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, mem), wasm, guestModuleConfig()) + }() + if err != nil { + _ = runtime.Close(ctx) + if g := mem.GrowthRefusal(); g.Refused != 0 { + return nil, fmt.Errorf("%w: %w", guest.MemoryLockError(g.Reason), err) + } + return nil, fmt.Errorf("stackencrypt: instantiating guest: %w", err) + } + if policy == guest.Strict { + if lerr := mem.LockError(); lerr != nil { + _ = runtime.Close(ctx) + return nil, guest.MemoryLockError(lerr) + } + } + inst := &instance{runtime: runtime, module: module, mem: mem} + exports := map[string]*api.Function{ + "se_alloc": &inst.exports.Alloc, + "se_dealloc": &inst.exports.Dealloc, + "se_cipher_init": &inst.cipherInit, + "se_shutdown": &inst.shutdown, + "se_keyset": &inst.keyset, + "se_encrypt": &inst.encrypt, + "se_encrypt_element": &inst.encryptElement, + "se_decrypt": &inst.decrypt, + "se_decrypt_element": &inst.decryptElement, + "se_term": &inst.term, + "se_encrypt_record": &inst.encryptRecord, + "se_decrypt_record": &inst.decryptRecord, + } + for name, slot := range exports { + if *slot = module.ExportedFunction(name); *slot == nil { + _ = runtime.Close(ctx) + return nil, fmt.Errorf("stackencrypt: guest is missing export %s", name) + } + } + return inst, nil +} + +// release runs the guest's shutdown — the client key and every loaded +// index key wiped inside the instance — and closes the runtime, which +// frees the linear memory through the allocator's wipe. It is what Close +// does, and what the cleanup on an unreachable Client does. A module an +// interrupted call or a trap already closed cannot run se_shutdown; the +// runtime close still wipes and frees its memory, so nothing is left +// behind either way. +func (inst *instance) release() error { + ctx := context.Background() + if !inst.module.IsClosed() { + inst.mem.Enter() + _, _ = inst.shutdown.Call(ctx) + inst.mem.Exit() + } + return inst.runtime.Close(ctx) +} + +// The call plumbing — stage each buffer argument through se_alloc, call, +// copy the output out, wipe and free every buffer before returning — is +// internal/guest's, shared with every guest package so the discipline is +// written once. What follows are this package's names for it. + +// errGuestTrap marks a guest export that did not return. See guest.ErrTrap. +var errGuestTrap = guest.ErrTrap + +func buf(data []byte) guest.Arg { return guest.BufArg(data) } +func scalar(v uint64) guest.Arg { return guest.ScalarArg(v) } + +// call stages every buffer argument, calls fn with the arguments in +// order, and copies the output out before every buffer — inputs and output +// — is wiped and freed. The memory stays mapped for the whole call; see +// guest.Call and guest.Allocator.Free. +func (inst *instance) call(ctx context.Context, fn api.Function, args ...guest.Arg) ([]byte, error) { + return guest.Call(ctx, inst.mem, inst.module, inst.exports, fn, args...) +} + +// wipe zeroes a host buffer. +func wipe(b []byte) { guest.Wipe(b) } diff --git a/languages/golang/stackencrypt/guest/.gitignore b/languages/golang/stackencrypt/guest/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/languages/golang/stackencrypt/guest/.gitignore @@ -0,0 +1 @@ +/target diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock new file mode 100644 index 000000000..8bb00e5ea --- /dev/null +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -0,0 +1,3013 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", + "zeroize", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cached" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.5", + "once_cell", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cipherstash-config" +version = "0.42.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d098935e395d7346d0cdc8cdf3ed9674ab03fa8b415e828d02e65c81836a73c" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cllw-ore" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476f300d37a5029d3d9dd57145d4db50a23f40ae9b4d1374c44543978b906191" +dependencies = [ + "blake3", + "hex", + "subtle", + "thiserror 1.0.69", + "unicode-normalization", + "zeroize", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest 0.10.7", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cts-common" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cb0f5ffa463e8facbe6ad78cfe925d132a051c6b1c9a5da2f3961296b7e632" +dependencies = [ + "arrayvec", + "base32", + "cached", + "chrono", + "derive_more", + "either", + "getrandom 0.4.3", + "miette", + "nom", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "utoipa", + "uuid", + "vitaminc", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", + "zeroize", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dummy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fake" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" +dependencies = [ + "deunicode", + "dummy", + "rand 0.8.8", + "uuid", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix", + "windows-targets", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mutants" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "recipher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e156e2d485b51cc67c19241e7d81ad524bda9fd4f77791b698ab10c8e26e9" +dependencies = [ + "aes", + "cmac", + "getrandom 0.2.17", + "hex", + "hex-literal", + "opaque-debug", + "rand 0.8.8", + "rand_chacha 0.3.1", + "serde", + "serde_cbor", + "sha2 0.10.9", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", + "zeroize", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.42.3" +dependencies = [ + "aquamarine", + "base64", + "cts-common", + "jsonwebtoken", + "miette", + "open", + "serde", + "serde_json", + "serde_urlencoded", + "stack-profile", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "web-time", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-encrypt" +version = "0.1.0" +dependencies = [ + "base64ct", + "cllw-ore", + "serde", + "stack-encrypt-derive", + "stack-kms", + "thiserror 1.0.69", + "uuid", + "vitaminc-aead", + "vitaminc-aead-value", + "vitaminc-encrypt", + "vitaminc-hmac", + "vitaminc-prf", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "stack-encrypt-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "stack-encrypt-guest" +version = "0.0.0" +dependencies = [ + "base16ct", + "base64ct", + "futures", + "recipher", + "serde", + "serde_json", + "stack-auth", + "stack-encrypt", + "stack-guest-abi", + "stack-kms", + "uuid", + "vitaminc-aead-value", + "vitaminc-protected", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-guest-abi" +version = "0.0.0" +dependencies = [ + "thiserror 1.0.69", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "stack-kms" +version = "0.1.0" +dependencies = [ + "base16ct", + "base64ct", + "blake3", + "futures", + "miette", + "opaque-debug", + "recipher", + "serde", + "serde_cbor", + "serde_json", + "serdect", + "sha2 0.10.9", + "stack-auth", + "stack-profile", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-profile" +version = "0.42.3" +dependencies = [ + "dirs", + "gethostname", + "serde", + "serde_json", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "url", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "md-5", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" +dependencies = [ + "darling 0.23.0", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vitaminc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +dependencies = [ + "vitaminc-aead", + "vitaminc-context", + "vitaminc-encrypt", + "vitaminc-protected", + "vitaminc-random", + "vitaminc-traits", +] + +[[package]] +name = "vitaminc-aead" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +dependencies = [ + "bytes", + "serde", + "vitaminc-aead-derive", + "vitaminc-context", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "vitaminc-aead-value" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +dependencies = [ + "vitaminc-aead", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-context" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +dependencies = [ + "mutants", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-hmac" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" +dependencies = [ + "hmac", + "sha2 0.11.0", + "vitaminc-prf", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-prf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" +dependencies = [ + "mutants", + "thiserror 2.0.20", + "vitaminc-context", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-protected" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.20", + "vitaminc-protected-derive", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "vitaminc-random" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand 0.10.2", + "thiserror 2.0.20", + "vitaminc-protected", + "vitaminc-random-derives", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "vitaminc-traits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.20", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerokms-protocol" +version = "0.12.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c28e88315a5109d0a1e7ee4b7b4b8776a0bff5f5b139ae83960a3debe84e92e" +dependencies = [ + "base64", + "cipherstash-config", + "const-hex", + "cts-common", + "fake", + "getrandom 0.2.17", + "opaque-debug", + "rand 0.8.8", + "serde", + "static_assertions", + "thiserror 1.0.69", + "utoipa", + "uuid", + "validator", + "zeroize", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/stackencrypt/guest/Cargo.toml new file mode 100644 index 000000000..69f82059e --- /dev/null +++ b/languages/golang/stackencrypt/guest/Cargo.toml @@ -0,0 +1,68 @@ +# stack-encrypt WASI guest: `StackCipher` over host-provided HTTP under +# WASI/wazero, embedded by the Go module one directory up (Phase 3/4 of +# docs/plans/stack-encrypt-go-bindings.md). +# +# Deliberately a standalone workspace (same pattern as the fuzz crates and +# vitaminc's guest): this crate targets wasm32-wasip1 and is consumed as a +# .wasm artifact, so its wasm-only profile and target must not leak into +# workspace builds. +# +# Build: mise run wasm:guest:build (or: cargo build --target wasm32-wasip1 --release) +[package] +name = "stack-encrypt-guest" +description = "WASI guest module exposing stack-encrypt to non-Rust hosts (Go/wazero)" +version = "0.0.0" +edition = "2021" +publish = false + +[workspace] + +[lib] +# cdylib: the .wasm guest module. rlib: lets the ops/config/status modules +# unit-test natively (`cargo test` here, no wasm toolchain needed). +crate-type = ["cdylib", "rlib"] + +[dependencies] +# The stack crates with default features off: no reqwest, no native TLS — +# HTTP comes from the host (see `wasm:wasi-check` in the suite root). +stack-auth = { path = "../../../../packages/stack-auth", default-features = false } +# `dynamic`: the runtime value model this guest speaks — contexts, index +# terms and record plans over `FfiValue`, shared with every other binding. +stack-encrypt = { path = "../../../../packages/stack-encrypt", default-features = false, features = ["dynamic"] } +# The guest ABI every guest under bindings/go shares: the allocator and +# buffer registry (and with them the `se_alloc`/`se_dealloc` exports), the +# packed-result helpers, the status table and the `transport_send` import. +# This crate defines only the exports that are its own. +stack-guest-abi = { path = "../../../../packages/stack-guest-abi" } +stack-kms = { path = "../../../../packages/stack-kms", default-features = false } +zerokms-protocol = "=0.12.31" + +# The FFI codec + `FfiValue` tree, shared with vitaminc's own guest — one +# codec, not a fork. Same vitaminc version stack-encrypt builds against +# (see the comment in packages/stack-encrypt/Cargo.toml). A different +# version here fails at the `FfiValue: Decrypt` bound, since the aead crate +# would be duplicated. +vitaminc-aead-value = "0.5.0" +vitaminc-protected = "0.5.0" + +futures = { version = "0.3", default-features = false, features = ["executor"] } +serde = "1" +serde_json = "1" +uuid = "1" +zeroize = "1" + +[dev-dependencies] +stack-kms = { path = "../../../../packages/stack-kms", default-features = false, features = ["test-support"] } +# For minting a valid client-key fixture in the config tests. +recipher = "=0.3.1" +# Re-encoding that fixture into the other forms the config table promises +# (upper-case hex, base64) so the lenient decode is actually exercised. +base16ct = { version = "0.2.0", features = ["alloc"] } +base64ct = { version = "1.7", features = ["alloc"] } + +[profile.release] +# Smaller .wasm; the guest is IO-bound on the FFI copy and the ZeroKMS round +# trip, not on codegen. +opt-level = "s" +lto = true +strip = true diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs new file mode 100644 index 000000000..74ffeb297 --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -0,0 +1,529 @@ +//! This guest's wasm export surface: the cipher exports, over the +//! conventions every guest shares (`stack_guest_abi::abi`: `se_alloc` / +//! `se_dealloc`, the buffer registry, the packed `u64` result encoding, the +//! hostile-input validation of every `(ptr, len)` pair). What is specific +//! to this guest: +//! +//! - **Every export handed plaintext wipes that buffer in place before it +//! returns**, rather than leaving it for `se_dealloc`: [`se_cipher_init`] +//! (the config carries the client key), and [`se_encrypt`], +//! [`se_encrypt_element`], [`se_term`] and [`se_encrypt_record`] (their +//! value/source buffers). The host's plaintext therefore lives no longer +//! than the call, instead of until the host gets round to releasing it. +//! **A host must not read a plaintext input buffer back after the call, or +//! pass the same buffer to two calls** — it will be zeros. Option, context, +//! AAD and plan buffers are not secret and are left untouched. +//! - Output buffers from the decrypt exports contain plaintext; the host must +//! copy them out and immediately `se_dealloc` (which zeroizes). +//! - **One instance is one client.** [`se_cipher_init`] runs once per +//! instance: it builds the +//! `StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>>` (one +//! `load-keyset` round trip through the host transport for the default +//! keyset) and returns that keyset's id. There is no cipher handle: the +//! keysets a client uses are selected per call through the options object +//! ([`crate::options`]), loaded on first use through the cipher's own +//! cache. Nothing crosses the boundary that the host could allocate, +//! alias or free. +//! - [`se_shutdown`] is the one lifetime call: it drops the cipher (client +//! key and every loaded index key wiped by `ZeroizeOnDrop`) and wipes +//! every buffer the registry still holds. It exists because closing a +//! wasm instance frees linear memory without running Rust destructors — +//! without it, key material would sit in freed host memory. After it, a +//! well-formed cipher operation is `STATUS_STATE`, as one before +//! [`se_cipher_init`] is, and so is a re-`se_cipher_init`. That is the +//! whole of the claim: `se_alloc` and `se_dealloc` return no status +//! and go on working — the host still has buffers to free — a second +//! [`se_shutdown`] is a no-op, and a *malformed* call is +//! `STATUS_ENCODING` in any state, because validation runs first (see +//! below). +//! - During an entry-point call the host's imported functions may re-enter +//! the guest **only** through `se_alloc` (to place the transport response +//! / token); calling any other export from inside a host import is +//! undefined behaviour of the embedding, not of this module. +//! +//! The value exports ([`se_encrypt`] and friends) are the cipher-directed +//! path and take the AAD as `KeysetCipher::encrypt` does: any bytes, none +//! included — a null pointer with zero length is the empty AAD, as a Go +//! `nil` slice is. The record and term exports bind fields, so their +//! contexts must be non-empty (`STATUS_ENCODING` otherwise): each is a +//! [`stack_encrypt::NonEmpty`] from the moment it is parsed, and the sealing +//! and opening sides bind that one value. The asymmetry is the design; see +//! `packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md`. +//! +//! Every export decodes and validates *all* of its inputs — the operation +//! payload, the plan or context, the term kind, the value against the +//! plan or kind, and the options object — before it consults the cipher +//! ([`ops::validate`] runs the operation's own parsers), so +//! malformed input reads as `STATUS_ENCODING` whether or not +//! `se_cipher_init` has run, and never costs a keyset load; only a +//! well-formed call with no cipher is `STATUS_STATE`. Keyset *resolution* +//! (a name or id the cipher has not loaded) is a round trip and so happens +//! inside the call, after every check. +//! +//! Wasm modules are single-threaded; the host must serialize calls into one +//! instance. + +use std::cell::{Cell, RefCell}; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use futures::executor::block_on; +use stack_encrypt::{KeysetCipher, StackCipher}; +use stack_guest_abi::abi::{err_status, input, ok_buffer, take_plaintext, wipe_input}; +use stack_guest_abi::buffers; +use stack_kms::{ClientOpts, StackKms}; +use vitaminc_aead_value::transport as codec; +use vitaminc_aead_value::FfiValue; + +use crate::config::parse_config; +use crate::host::{HostTokenStrategy, WasiHostConnection}; +use crate::ops; +use crate::options::{parse_options, parse_selector, scope_for, KeysetSelector, Side}; +use crate::status::{STATUS_ENCODING, STATUS_INTERNAL, STATUS_KMS_TRANSPORT, STATUS_STATE}; +use stack_encrypt::dynamic::Scope; + +/// The instance's cipher: `stack-encrypt` over the host-transport ZeroKMS +/// client with host-supplied tokens. +type GuestCipher = StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>>; + +thread_local! { + // Wasm is single-threaded, so a thread-local `RefCell` is a plain owner + // of the cipher — no `Send`/`Sync` bounds required. + static CIPHER: RefCell<Option<GuestCipher>> = const { RefCell::new(None) }; + /// Set by `se_shutdown`: after it, `se_cipher_init` is refused too, so + /// an instance the host has torn down cannot be quietly revived with + /// stale buffers around. + static SHUT_DOWN: Cell<bool> = const { Cell::new(false) }; +} + +/// Decode one codec-encoded input. +fn decode(bytes: &[u8]) -> Result<FfiValue, u32> { + codec::decode_value(&mut codec::Reader::new(bytes)).map_err(|_| STATUS_ENCODING) +} + +/// Run `f` with the instance's cipher, or report `STATUS_STATE` when there +/// is none (never initialised, or shut down). +fn with_cipher<R>(f: impl FnOnce(&GuestCipher) -> Result<R, u32>) -> Result<R, u32> { + CIPHER.with(|c| { + let c = c.borrow(); + let cipher = c.as_ref().ok_or(STATUS_STATE)?; + f(cipher) + }) +} + +/// Run `f` with the keyset the mint-side options in `opts` select, +/// resolving it through the cipher (a first use is one `load-keyset` round +/// trip). The options are decoded and validated before the cipher is +/// consulted. +fn with_keyset<R>( + opts: &[u8], + f: impl FnOnce(&KeysetCipher<'_, StackKms<HostTokenStrategy, WasiHostConnection>>) -> Result<R, u32>, +) -> Result<R, u32> { + let options = parse_options(decode(opts)?, Side::Mint)?; + with_cipher(|cipher| { + let keyset = block_on(options.keyset.resolve(cipher))?; + f(&keyset) + }) +} + +/// Run `f` with the scope the open-side options in `opts` select: the +/// client for `{"any"}`, one keyset's cipher otherwise. +fn with_scope<R>( + opts: &[u8], + f: impl FnOnce(Scope<'_, StackKms<HostTokenStrategy, WasiHostConnection>>) -> Result<R, u32>, +) -> Result<R, u32> { + let options = parse_options(decode(opts)?, Side::Open)?; + with_cipher(|cipher| { + let scope = block_on(scope_for(cipher, &options.keyset))?; + f(scope) + }) +} + +/// Initialise the instance's cipher from an FFI-codec-encoded config object +/// (see [`crate::config`]). Performs one `load-keyset` round trip through +/// the host transport for the default keyset, and returns that keyset's id +/// (16 raw UUID bytes) as the output buffer. The raw config buffer — which +/// carries the client-key hex — is wiped in place before any network +/// traffic, whatever the outcome. +/// +/// Once per instance: a second call, or a call after [`se_shutdown`], is +/// `STATUS_STATE` once the config parses — a config that does not parse is +/// `STATUS_ENCODING` first, like any malformed input. Either way the config +/// buffer is wiped. +/// +/// # Safety +/// +/// `cfg_ptr`/`cfg_len` should name the buffer the host wrote the config +/// into. The guest bounds-checks the range against linear memory — a bad +/// pair returns `STATUS_ENCODING` instead of faulting — but cannot verify +/// the bytes are the ones the host intended. +#[no_mangle] +pub unsafe extern "C" fn se_cipher_init(cfg_ptr: *mut u8, cfg_len: u32) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + // The pointer/length pair is validated first and on its own: a pair + // that fails here returns before anything touches the range, which + // is `wipe_input`'s precondition. Only a validated buffer is decoded + // and, whatever the decode outcome, wiped. + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let bytes = unsafe { input(cfg_ptr, cfg_len)? }; + let decoded = decode(bytes); + // The borrow of the raw buffer ends with `decoded` owned; wipe the + // buffer now — it holds the client-key hex — before parsing (and + // before the init round trip), whatever the decode outcome. + unsafe { wipe_input(cfg_ptr, cfg_len) }; + cipher_init(decoded?) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +fn cipher_init(decoded: FfiValue) -> Result<Vec<u8>, u32> { + let config = parse_config(decoded).map_err(|_| STATUS_ENCODING)?; + if SHUT_DOWN.with(Cell::get) || CIPHER.with(|c| c.borrow().is_some()) { + return Err(STATUS_STATE); + } + + // One request at a time: the host import is synchronous, so concurrency + // would only interleave nothing; keep the executor honest about it. + // + // `max_keys_per_req` stays at the client default (500). That is what + // bounds "one ZeroKMS call": a batch is assembled once, then + // `Client::send_chunked` splits it into sequential requests of at most + // that many keys — so a 1200-leaf record batch is three calls, not one. + // Raising it here would trade a documented, server-friendly request size + // for a claim the server need not honour, so the bound is kept and the + // docs say 500 rather than "one". + let opts = ClientOpts::new(config.endpoint) + .with_max_concurrent_reqs(1) + .map_err(|_| STATUS_INTERNAL)?; + let kms = StackKms::<HostTokenStrategy, WasiHostConnection>::connect( + opts, + HostTokenStrategy, + config.client_key, + ) + .map_err(|_| STATUS_KMS_TRANSPORT)?; + + let mut builder = StackCipher::builder().kms(kms); + if let Some(size) = config.keyset_cache_size { + builder = builder.keyset_cache_size(size); + } + let cipher = block_on(builder.init()).map_err(|e| crate::status::status_for_error(&e))?; + let default = cipher.default_keyset().keyset_id().as_bytes().to_vec(); + CIPHER.with(|c| *c.borrow_mut() = Some(cipher)); + Ok(default) +} + +/// Tear the instance down: drop the cipher — the client key and every +/// loaded keyset's index key are wiped by `ZeroizeOnDrop` — and wipe every +/// buffer the registry still holds, so nothing the host forgot to +/// `se_dealloc` survives in freed memory. Idempotent — a second call is a +/// no-op, and `se_alloc`/`se_dealloc` keep working so the host can +/// still free what it holds. Afterwards every well-formed cipher operation +/// is `STATUS_STATE`, [`se_cipher_init`] included; a malformed one is +/// `STATUS_ENCODING` first, as in any other state. +/// +/// The index keys' wipe holds because each `HmacSha256Prf` clone a +/// derivation takes is created and dropped inside one `block_on`'d call, +/// and the guest is single-threaded, so no clone is alive when the host +/// calls this. That is the precondition, not a property of the drop: +/// anything that later parks a PRF clone beyond an ABI call turns this +/// wipe into a no-op for that key. +#[no_mangle] +pub extern "C" fn se_shutdown() { + let _ = catch_unwind(AssertUnwindSafe(|| { + SHUT_DOWN.with(|s| s.set(true)); + CIPHER.with(|c| { + let _ = c.borrow_mut().take(); + }); + buffers::wipe_all(); + })); +} + +/// Resolve a keyset selector (a codec-encoded tagged object — see +/// [`crate::options`]; `{"any"}` is not a keyset and is `STATUS_ENCODING` +/// here, before the cipher is consulted) through the cipher's cache and return the keyset's id (16 raw UUID +/// bytes). A first use of a keyset is one `load-keyset` round trip; a host +/// can call this at boot to validate a tenant's keyset and learn its id. +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_keyset(sel_ptr: *const u8, sel_len: u32) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let selector = parse_selector(decode(unsafe { input(sel_ptr, sel_len)? })?)?; + if selector == KeysetSelector::Any { + return Err(STATUS_ENCODING); + } + with_cipher(|cipher| { + let keyset = block_on(selector.resolve(cipher))?; + Ok(keyset.keyset_id().as_bytes().to_vec()) + }) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// Encrypt an FFI-codec-encoded value tree under the keyset `opts` selects, +/// binding `aad`; every leaf is sealed from one batched key request, +/// dispatched as one `generate-data-key` call per 500 keyed leaves (see +/// `cipher_init` for where that bound comes from). Output: packed pointer +/// to a codec-encoded ciphertext tree whose leaves are the frozen +/// `SealedValue` byte encoding, each carrying the keyset's id. +/// +/// `aad` may be empty (a null pointer with zero length is empty) — see this +/// module's hostile-input notes. `opts` is the options object +/// (`{"keyset": <selector>}`, [`crate::options`]); `{"any"}` is refused here. +/// +/// # Safety +/// +/// Pointer/length pairs should name buffers the host wrote via +/// `se_alloc`; each range is bounds-checked against linear memory (a bad +/// pair returns `STATUS_ENCODING` instead of faulting). +#[no_mangle] +pub unsafe extern "C" fn se_encrypt( + val_ptr: *mut u8, + val_len: u32, + aad_ptr: *const u8, + aad_len: u32, + opt_ptr: *const u8, + opt_len: u32, +) -> u64 { + run_encrypt(val_ptr, val_len, aad_ptr, aad_len, opt_ptr, opt_len, false) +} + +/// Like [`se_encrypt`], but seals the value as a *sequence element* — rows +/// written through this export interchange with rows written by encrypting +/// a whole sequence under the same AAD. +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_encrypt_element( + val_ptr: *mut u8, + val_len: u32, + aad_ptr: *const u8, + aad_len: u32, + opt_ptr: *const u8, + opt_len: u32, +) -> u64 { + run_encrypt(val_ptr, val_len, aad_ptr, aad_len, opt_ptr, opt_len, true) +} + +/// Decrypt a codec-encoded ciphertext tree back into a codec-encoded value +/// tree; one batched key request per keyset the leaves were sealed under, +/// dispatched as one `retrieve-data-key` call per 500 keyed leaves. The +/// output buffer contains **plaintext** — the host must copy it out and +/// immediately release it with `se_dealloc` (which wipes it). +/// +/// `aad` must be the one the ciphertext was sealed under, empty included. +/// `opts` constrains which keyset may be opened: `{"any"}` opens leaves from +/// whichever keyset each was sealed under; `{"name"}`, `{"id"}` and +/// `{"default"}` refuse a leaf from any other keyset as +/// `STATUS_FOREIGN_KEYSET`, before any key is retrieved. +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_decrypt( + ct_ptr: *const u8, + ct_len: u32, + aad_ptr: *const u8, + aad_len: u32, + opt_ptr: *const u8, + opt_len: u32, +) -> u64 { + run_decrypt(ct_ptr, ct_len, aad_ptr, aad_len, opt_ptr, opt_len, false) +} + +/// Like [`se_decrypt`], but opens the ciphertext as a *sequence element* — +/// the read-side counterpart of [`se_encrypt_element`], for one row of a +/// batch-encrypted sequence under the batch's AAD. +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_decrypt_element( + ct_ptr: *const u8, + ct_len: u32, + aad_ptr: *const u8, + aad_len: u32, + opt_ptr: *const u8, + opt_len: u32, +) -> u64 { + run_decrypt(ct_ptr, ct_len, aad_ptr, aad_len, opt_ptr, opt_len, true) +} + +/// [`se_encrypt`] / [`se_encrypt_element`]'s shared drive: validate, select +/// the keyset, block on the op. +fn run_encrypt( + val_ptr: *mut u8, + val_len: u32, + aad_ptr: *const u8, + aad_len: u32, + opt_ptr: *const u8, + opt_len: u32, + as_element: bool, +) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + // Plaintext first: the wipe writes through `&mut`, so nothing else + // may be borrowed from linear memory yet. + let value = unsafe { take_plaintext(val_ptr, val_len)? }; + let value = value.as_slice(); + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let aad = unsafe { input(aad_ptr, aad_len)? }; + let opts = unsafe { input(opt_ptr, opt_len)? }; + ops::validate::value(value)?; + with_keyset(opts, |keyset| { + block_on(ops::encrypt_value(keyset, value, aad, as_element)) + }) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// [`se_decrypt`] / [`se_decrypt_element`]'s shared drive. +fn run_decrypt( + ct_ptr: *const u8, + ct_len: u32, + aad_ptr: *const u8, + aad_len: u32, + opt_ptr: *const u8, + opt_len: u32, + as_element: bool, +) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let ciphertext = unsafe { input(ct_ptr, ct_len)? }; + let aad = unsafe { input(aad_ptr, aad_len)? }; + let opts = unsafe { input(opt_ptr, opt_len)? }; + ops::validate::tree(ciphertext)?; + with_scope(opts, |scope| { + block_on(ops::decrypt_value(scope, ciphertext, aad, as_element)) + }) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// Derive one index term under the keyset `opts` selects: a codec-encoded +/// scalar, a codec-encoded context and a term kind ([`ops::TERM_EQUALITY`] +/// etc.); the output is the term's frozen byte encoding. Under the local +/// HMAC backend this is one PRF/CLLW derivation with no ZeroKMS I/O; that +/// is the backend's property, not this export's contract. +/// +/// The context is one part — a string, bytes, or an `i32`/`i64`/`u32`/`u64` +/// — or an array of parts, which may nest as deep as the transport codec +/// allows (`vitaminc_aead_value::transport::MAX_DEPTH` levels, counted from +/// the root of the encoded value; deeper is refused as `STATUS_ENCODING` +/// before the context is parsed). [`stack_encrypt::dynamic::context`] is the one home of +/// that grammar and of which Rust context each shape spells. +/// A part and the one-element array holding it are *different* contexts +/// (`[x]` is PAE-framed, `x` is not), so a probe must pass the context in +/// exactly the shape the field was sealed under: a plan field's context +/// verbatim, a bare part for a Rust leaf sealed under that part. +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_term( + val_ptr: *mut u8, + val_len: u32, + ctx_ptr: *const u8, + ctx_len: u32, + kind: u32, + opt_ptr: *const u8, + opt_len: u32, +) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + let value = unsafe { take_plaintext(val_ptr, val_len)? }; + let value = value.as_slice(); + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let context = unsafe { input(ctx_ptr, ctx_len)? }; + let opts = unsafe { input(opt_ptr, opt_len)? }; + ops::validate::term(value, context, kind)?; + with_keyset(opts, |keyset| { + block_on(ops::term(keyset, value, context, kind)) + }) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// Encrypt a record (or a batch) per a plan under the keyset `opts` selects +/// — the runtime form of `#[derive(EncryptFrom)]`; see +/// [`ops::encrypt_record`] for the source, plan, and result encodings. All +/// rows and fields seal from **one** batched key request regardless of row +/// count — dispatched as one `generate-data-key` call per 500 keyed leaves, +/// sequentially — and terms derive under the same keyset's index key. +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_encrypt_record( + src_ptr: *mut u8, + src_len: u32, + plan_ptr: *const u8, + plan_len: u32, + opt_ptr: *const u8, + opt_len: u32, +) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + let source = unsafe { take_plaintext(src_ptr, src_len)? }; + let source = source.as_slice(); + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let plan = unsafe { input(plan_ptr, plan_len)? }; + let opts = unsafe { input(opt_ptr, opt_len)? }; + ops::validate::record(source, plan)?; + with_keyset(opts, |keyset| { + block_on(ops::encrypt_record(keyset, source, plan)) + }) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// Decrypt a record (or a batch) produced by [`se_encrypt_record`] under +/// the same plan; only the `"c"` outputs participate. One batched key +/// request per keyset the leaves were sealed under, dispatched as one +/// `retrieve-data-key` call per 500 keyed leaves. `opts` constrains the +/// keyset as for [`se_decrypt`]. The output buffer contains **plaintext** — +/// same host obligations as [`se_decrypt`]. +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_decrypt_record( + rec_ptr: *const u8, + rec_len: u32, + plan_ptr: *const u8, + plan_len: u32, + opt_ptr: *const u8, + opt_len: u32, +) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let record = unsafe { input(rec_ptr, rec_len)? }; + let plan = unsafe { input(plan_ptr, plan_len)? }; + let opts = unsafe { input(opt_ptr, opt_len)? }; + ops::validate::record_tree(record, plan)?; + with_scope(opts, |scope| { + block_on(ops::decrypt_record(scope, record, plan)) + }) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} diff --git a/languages/golang/stackencrypt/guest/src/config.rs b/languages/golang/stackencrypt/guest/src/config.rs new file mode 100644 index 000000000..c12c4a9d8 --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/config.rs @@ -0,0 +1,322 @@ +//! Parsing of the `se_cipher_init` configuration. +//! +//! The config crosses the boundary as one FFI-codec-encoded +//! [`FfiValue::Object`] — the same codec every other entry point uses, so +//! there is no second config format. Recognised keys (all string values): +//! +//! | key | required | meaning | +//! |---------------|----------|---------| +//! | `client_id` | yes | ZeroKMS client id (UUID) | +//! | `client_key` | yes | the v1 client key material, hex-encoded (upper or lower case — the `to_hex_v1` / `CS_CLIENT_KEY` form) or standard padded base64 (the form `secretkey.json` serialises) | +//! | `zerokms_url` | no | pins the ZeroKMS endpoint at init; when absent the endpoint is resolved from the access token's `services` claim on first use | +//! | `keyset_cache_size` | no | how many keysets beyond the default the cipher keeps loaded (a positive decimal integer; the crate default, 1024, when absent). See `StackCipherBuilder::keyset_cache_size` | +//! +//! There is no config key for a keyset. `{"default"}` means the default a +//! ZeroKMS administrator set for this client, and a client does not get to +//! redefine it — the same reason `StackCipherBuilder::keyset` was removed. +//! Every other keyset is selected per call (see [`crate::options`]). Unknown +//! keys are rejected — a typo'd optional key must not silently fall back to a +//! default — so a host still sending `keyset` is told so rather than quietly +//! encrypting somewhere else. +//! +//! The parsed [`FfiValue`] holds the client-key hex inside +//! `Protected`, which wipes on drop; the raw config *buffer* is wiped by the +//! ABI layer immediately after decoding (see [`crate::abi`]). + +use std::num::NonZeroUsize; + +use stack_kms::{ClientKey, ZeroKmsEndpoint}; +use uuid::Uuid; +use vitaminc_aead_value::FfiValue; +use zeroize::Zeroizing; + +/// A parse failure, carrying which key was at fault. Maps to +/// `STATUS_ENCODING` at the ABI; the detail exists for the native tests and +/// is never surfaced across the boundary (statuses leak no config content). +#[derive(Debug, PartialEq, Eq)] +pub enum ConfigError { + /// The config was not an object of string values. + NotAnObject, + /// A required key was absent. + Missing(&'static str), + /// A key held something other than a string. + NotAString(&'static str), + /// A key's value failed its own validation (bad UUID, bad hex, bad URL). + Invalid(&'static str), + /// A key appeared twice. The codec rejects duplicate object keys before + /// this parser runs, but `parse_config` is `pub` and takes any + /// [`FfiValue`] — last-write-wins on, say, `client_key` must never be + /// silent. + Duplicate(&'static str), + /// A key this version does not recognise. + UnknownKey(String), +} + +/// Everything `se_cipher_init` needs to build the cipher. +pub struct CipherConfig { + pub client_key: ClientKey, + pub endpoint: Option<ZeroKmsEndpoint>, + pub keyset_cache_size: Option<NonZeroUsize>, +} + +/// Parse a decoded config value. Consumes it so the client-key material has +/// one owner; the `Protected` payloads are wiped when the strings drop here. +pub fn parse_config(value: FfiValue) -> Result<CipherConfig, ConfigError> { + let FfiValue::Object(entries) = value else { + return Err(ConfigError::NotAnObject); + }; + + // Every slot is a `Zeroizing<String>`, not just the key one: the + // client-key slot *must* wipe on every exit path (any of the `?`s below + // can fire while it holds a full encoding of the client root key), and + // making one slot special invites the next edit to add an early return + // above the wipe. Uniform is cheaper than remembering. + let mut client_id: Option<Zeroizing<String>> = None; + let mut client_key_encoded: Option<Zeroizing<String>> = None; + let mut url: Option<Zeroizing<String>> = None; + let mut cache_size: Option<Zeroizing<String>> = None; + + for (key, value) in entries { + let slot = match key.as_str() { + "client_id" => &mut client_id, + "client_key" => &mut client_key_encoded, + "zerokms_url" => &mut url, + "keyset_cache_size" => &mut cache_size, + _ => return Err(ConfigError::UnknownKey(key)), + }; + // The codec already rejects duplicate object keys, so on the ABI + // path the slot is always vacant — but this function accepts any + // `FfiValue`, so enforce it rather than assume it. + if slot.is_some() { + return Err(ConfigError::Duplicate(name_of(&key))); + } + let FfiValue::String(s) = value else { + return Err(ConfigError::NotAString(name_of(&key))); + }; + let text = Zeroizing::new( + std::str::from_utf8(s.risky_ref()) + .map_err(|_| ConfigError::NotAString(name_of(&key)))? + .to_string(), + ); + *slot = Some(text); + } + + let client_id = client_id.ok_or(ConfigError::Missing("client_id"))?; + let client_id = Uuid::parse_str(&client_id).map_err(|_| ConfigError::Invalid("client_id"))?; + + // Lenient by design: `from_encoded_v1` takes hex in either case *or* the + // base64 `secretkey.json` holds, matching every native loader. The + // `Zeroizing` slot wipes the encoded copy however this returns — the + // decoded `FfiValue`'s own copy was consumed above, and the raw input + // buffer is the ABI layer's to wipe. + let client_key_encoded = client_key_encoded.ok_or(ConfigError::Missing("client_key"))?; + let client_key = ClientKey::from_encoded_v1(client_id, &client_key_encoded) + .map_err(|_| ConfigError::Invalid("client_key"))?; + + let endpoint = url + .map(|u| u.parse().map_err(|_| ConfigError::Invalid("zerokms_url"))) + .transpose()?; + + let keyset_cache_size = cache_size + .map(|n| { + n.parse::<NonZeroUsize>() + .map_err(|_| ConfigError::Invalid("keyset_cache_size")) + }) + .transpose()?; + + Ok(CipherConfig { + client_key, + endpoint, + keyset_cache_size, + }) +} + +/// Intern the key name for error reporting (`&'static str` keeps +/// [`ConfigError`] cheap; unknown keys carry the owned string instead). +fn name_of(key: &str) -> &'static str { + match key { + "client_id" => "client_id", + "client_key" => "client_key", + "zerokms_url" => "zerokms_url", + "keyset_cache_size" => "keyset_cache_size", + _ => "unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A valid v1 client key for fixtures, minted through the real key type + /// so the hex exercises the actual `from_hex_v1` decoder. + fn client_key_hex() -> (Uuid, String) { + use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + let id = Uuid::from_u128(7); + let authority = EncryptionKeySet::generate().expect("generate"); + let domain = EncryptionKeySet::generate().expect("generate"); + let key = ClientKey::new_v1(id, ProxyKeySet::generate(&authority, &domain)); + (id, key.to_hex_v1().expect("encode fixture key")) + } + + fn obj(entries: Vec<(&str, &str)>) -> FfiValue { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), FfiValue::String(v.into()))) + .collect(), + ) + } + + #[test] + fn parses_a_minimal_config() { + let (id, hex) = client_key_hex(); + let cfg = parse_config(obj(vec![ + ("client_id", &id.to_string()), + ("client_key", &hex), + ])) + .expect("minimal config parses"); + assert_eq!(cfg.client_key.key_id, id); + assert!(cfg.endpoint.is_none()); + assert!(cfg.keyset_cache_size.is_none()); + } + + #[test] + fn parses_the_keyset_cache_size() { + let (id, hex) = client_key_hex(); + let id_s = id.to_string(); + let cfg = parse_config(obj(vec![ + ("client_id", &id_s), + ("client_key", &hex), + ("keyset_cache_size", "16"), + ])) + .expect("config parses"); + assert_eq!(cfg.keyset_cache_size, NonZeroUsize::new(16)); + + for bad in ["0", "-1", "sixteen", ""] { + assert!( + matches!( + parse_config(obj(vec![ + ("client_id", &id_s), + ("client_key", &hex), + ("keyset_cache_size", bad), + ])), + Err(ConfigError::Invalid("keyset_cache_size")) + ), + "{bad:?} must be refused" + ); + } + } + + /// A keyset is not a config key: the default is the server's and a + /// client does not redefine it, so a host that still sends one is told, + /// rather than silently encrypting under the client's default instead of + /// the keyset it named. + #[test] + fn a_keyset_key_is_rejected_like_any_other_unknown_key() { + let (id, hex) = client_key_hex(); + for key in ["keyset", "keyset_id"] { + assert!(matches!( + parse_config(obj(vec![ + ("client_id", &id.to_string()), + ("client_key", &hex), + (key, "users"), + ])), + Err(ConfigError::UnknownKey(_)) + )); + } + } + + #[test] + fn parses_the_zerokms_url() { + let (id, hex) = client_key_hex(); + let cfg = parse_config(obj(vec![ + ("client_id", &id.to_string()), + ("client_key", &hex), + ("zerokms_url", "https://zerokms.example.com"), + ])) + .expect("config parses"); + assert!(cfg.endpoint.is_some()); + } + + /// The config table promises hex in either case *or* base64 — the form + /// `secretkey.json` actually serialises. A user pasting the value out of + /// their profile must not be told their key is invalid. + #[test] + fn accepts_the_encodings_every_native_loader_accepts() { + use base64ct::Encoding; + + let (id, hex) = client_key_hex(); + let id_s = id.to_string(); + let bytes = base16ct::lower::decode_vec(&hex).expect("fixture hex"); + let base64 = base64ct::Base64::encode_string(&bytes); + + for (label, encoded) in [ + ("lowercase hex", hex.clone()), + ("uppercase hex", hex.to_uppercase()), + ("base64", base64), + ] { + let cfg = parse_config(obj(vec![("client_id", &id_s), ("client_key", &encoded)])) + .unwrap_or_else(|e| panic!("{label} must parse, got {e:?}")); + assert_eq!(cfg.client_key.key_id, id, "{label}"); + assert_eq!( + cfg.client_key.to_hex_v1().expect("re-encode"), + hex, + "{label} must recover the same keyset" + ); + } + } + + #[test] + fn rejects_bad_configs() { + let (id, hex) = client_key_hex(); + let id_s = id.to_string(); + + assert!(matches!( + parse_config(FfiValue::Null), + Err(ConfigError::NotAnObject) + )); + assert!(matches!( + parse_config(obj(vec![("client_id", &id_s)])), + Err(ConfigError::Missing("client_key")) + )); + assert!(matches!( + parse_config(obj(vec![("client_id", "nope"), ("client_key", &hex)])), + Err(ConfigError::Invalid("client_id")) + )); + assert!(matches!( + parse_config(obj(vec![ + ("client_id", &id_s), + ("client_key", "deadbeef"), // valid hex, not a keyset + ])), + Err(ConfigError::Invalid("client_key")) + )); + assert!(matches!( + parse_config(obj(vec![ + ("client_id", &id_s), + ("client_key", &hex), + ("zerokms_urk", "https://typo.example.com"), + ])), + Err(ConfigError::UnknownKey(_)) + )); + // The codec refuses duplicate keys on the ABI path, but this + // function is `pub` over any `FfiValue`: a repeated `client_key` + // must be an error, never a silent last-write-wins on root key + // material. + assert!(matches!( + parse_config(obj(vec![ + ("client_id", &id_s), + ("client_key", &hex), + ("client_key", &hex), + ])), + Err(ConfigError::Duplicate("client_key")) + )); + assert!(matches!( + parse_config(obj(vec![ + ("client_id", &id_s), + ("client_key", &hex), + ("zerokms_url", "not a url"), + ])), + Err(ConfigError::Invalid("zerokms_url")) + )); + } +} diff --git a/languages/golang/stackencrypt/guest/src/headers.rs b/languages/golang/stackencrypt/guest/src/headers.rs new file mode 100644 index 000000000..e1c0a2f36 --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/headers.rs @@ -0,0 +1,87 @@ +//! The headers this guest's ZeroKMS requests carry, over the wire format +//! every guest shares ([`stack_guest_abi::headers`]: `name: value` lines). + +use std::sync::OnceLock; + +use stack_guest_abi::headers::encode_headers; + +/// The host this guest is driven by, as it appears in [`user_agent`]. +/// +/// One token, because today there is one build. `stack-encrypt-ffi` (the +/// plan in #2209) builds the same ABI crate as this WASI guest *and* as a +/// native cdylib for C, C++ and Python, at which point this becomes a +/// per-build value rather than a constant. Letting the host contribute its +/// own token as well — an application's `myapp/1.0` after ours — is a +/// deliberate follow-up: what ships now is one string this crate controls, +/// not an extension point with a single user. +const HOST: &str = "Go"; + +/// The `user-agent` every ZeroKMS request carries. +/// +/// Not optional, and not cosmetic: the edge in front of production ZeroKMS +/// refuses a request that arrives without one — and refuses a host +/// runtime's generic default too (`Go-http-client/1.1` is rejected) — with a +/// bare nginx 403 that never reaches the application. The native client sets +/// one in `stack_kms::user_agent`; the guest builds its own requests and +/// never goes through that path, so it has to say who it is here. +/// +/// It names the *library* and the host carrying it, not this crate — a +/// `stack-encrypt/0.1.0 (Go)`: the `stack-encrypt/0.1.0` product token +/// means the same thing from Rust, from here, or from a native cdylib, and +/// the guest shim's own version number would say nothing anyone reading a +/// log wants to know. +pub fn user_agent() -> &'static str { + static USER_AGENT: OnceLock<String> = OnceLock::new(); + USER_AGENT.get_or_init(|| format!("stack-encrypt/{} ({HOST})", stack_encrypt::VERSION)) +} + +/// The headers of a ZeroKMS request: the bearer credential, the content +/// type, and [`user_agent`]. +/// +/// This lives here rather than at the call site because `host` is +/// `#[cfg(target_arch = "wasm32")]` and so is never compiled — let alone +/// tested — on the native target. The header set is the kind of thing that +/// fails in production and nowhere else, so it belongs in a module the test +/// suite can see. +pub fn request_headers(authorization: &str) -> Vec<u8> { + encode_headers(&[ + ("authorization", authorization), + ("content-type", "application/json"), + ("user-agent", user_agent()), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + use stack_guest_abi::headers::header_value; + + /// The edge in front of production ZeroKMS answers a request with no + /// `user-agent` with a bare nginx 403, before the application sees it. + /// Every request must carry one, and it must not be a host runtime's + /// generic default — those are refused too. + #[test] + fn every_request_identifies_itself() { + let headers = request_headers("Bearer tok"); + let ua = header_value(&headers, "user-agent").expect("requests carry a user-agent"); + assert_eq!( + ua, + format!("stack-encrypt/{} (Go)", stack_encrypt::VERSION), + "the user-agent names the library and the host carrying it" + ); + assert!( + !ua.contains("Go-http-client"), + "a host runtime's default user-agent is refused by the edge" + ); + assert_eq!( + header_value(&headers, "authorization"), + Some("Bearer tok"), + "the credential travels with the user-agent" + ); + assert_eq!( + header_value(&headers, "content-type"), + Some("application/json"), + "the content type travels with the user-agent" + ); + } +} diff --git a/languages/golang/stackencrypt/guest/src/host.rs b/languages/golang/stackencrypt/guest/src/host.rs new file mode 100644 index 000000000..7c7564f5f --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/host.rs @@ -0,0 +1,197 @@ +//! The host imports and the types built over them: [`WasiHostConnection`] +//! (a [`ZeroKMSConnection`] whose transport is the host's HTTP client) and +//! [`HostTokenStrategy`] (an [`AuthStrategy`] that asks the host for the +//! bearer token). wasm32-only — everything here calls an imported function. +//! +//! # Import contract (module `cipherstash_transport`) +//! +//! All pointers are offsets into guest linear memory; the host allocates +//! guest buffers with `se_alloc` and the guest reclaims them through the +//! shared registry (`stack_guest_abi::buffers`). +//! +//! - `transport_send(..)` — perform one HTTP request. The import and its +//! contract are `stack_guest_abi::transport`'s, shared with every guest; +//! this module only builds ZeroKMS requests over it. +//! - `token_get(token_out) -> status` — hand over the current bearer token +//! (Phase-1 auth: minting and refresh stay on the host). `token_out` is a +//! `(ptr_out, len_out)` slot pair filled with an `se_alloc`'d buffer; +//! status `0` is success, anything else a host-side failure. This +//! guest's own: the credential guest supplies tokens rather than asking +//! for them. +//! +//! What crosses the boundary per ZeroKMS call is exactly what would cross +//! TLS anyway: the URL, the bearer token, and the serialized protocol +//! bytes. Key material derived from a response never crosses back — the +//! derivation runs inside the guest ([`stack_kms`]'s client, unmodified). +//! +//! Request bodies and response bodies can carry key-material contexts and +//! wrapped keys, so both are wiped on drop here; the buffers the host wrote +//! are reclaimed via the registry (which the ABI's `se_dealloc` also wipes). + +use std::convert::Infallible; +use std::sync::Mutex; + +use stack_auth::{AuthError, AuthStrategy, CustomError, SecretToken, ServiceToken}; +use stack_guest_abi::buffers; +use stack_guest_abi::headers::header_value; +use stack_guest_abi::transport; +use stack_kms::{BaseUrlUnresolved, ZeroKMSConnection, ZeroKMSConnectionInit, ZeroKmsEndpoint}; +use zeroize::Zeroizing; +use zerokms_protocol::{ViturRequest, ViturRequestError}; + +use crate::headers::request_headers; +use crate::response::map_response; + +#[link(wasm_import_module = "cipherstash_transport")] +extern "C" { + fn token_get(token_ptr_out: *mut u32, token_len_out: *mut u32) -> i32; +} + +/// A [`ZeroKMSConnection`] whose transport is the `transport_send` host +/// import. The host call is synchronous from the guest's perspective, so +/// `send` resolves immediately — `block_on` in the ABI layer never parks. +/// +/// The endpoint is pinned at init when the cipher config named one; +/// otherwise it is discovered by `StackKms` from the access token's +/// `services` claim through [`ensure_base_url`](Self::ensure_base_url) — +/// the first value wins, per the trait's contract. +pub struct WasiHostConnection { + base: Mutex<Option<ZeroKmsEndpoint>>, +} + +impl WasiHostConnection { + fn base(&self) -> std::sync::MutexGuard<'_, Option<ZeroKmsEndpoint>> { + // Wasm is single-threaded: a poisoned lock can only mean a previous + // panic already aborted the instance, so this is unreachable — + // recover rather than add a second panic path. + self.base + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl ZeroKMSConnectionInit for WasiHostConnection { + type ConnectionOpts = Option<ZeroKmsEndpoint>; + type Error = Infallible; + + fn init(opts: Self::ConnectionOpts) -> Result<Self, Infallible> { + Ok(Self { + base: Mutex::new(opts), + }) + } +} + +impl ZeroKMSConnection for WasiHostConnection { + fn ensure_base_url(&self, url: ZeroKmsEndpoint) { + let mut base = self.base(); + if base.is_none() { + *base = Some(url); + } + } + + fn has_base_url(&self) -> bool { + self.base().is_some() + } + + async fn send<Request: ViturRequest>( + &self, + request: Request, + access_token: &str, + ) -> Result<Request::Response, ViturRequestError> { + // Defence in depth, shared verbatim with `HttpConnection`: + // `StackKms::get_token` resolves the endpoint (or fails with + // `AuthError::InvalidToken`, which the status layer reports as + // `STATUS_KMS_TRANSPORT`) before any caller reaches here, so on the + // client's own paths this is unreachable. `send` is public trait API + // though, and a *prepare* error is the answer that keeps a direct + // caller from mistaking a missing endpoint for a 401 and refreshing + // in a loop. + let url = self + .base() + .as_ref() + .map(|base| base.request_url(Request::ENDPOINT)) + .ok_or_else(|| { + ViturRequestError::prepare( + "ZeroKMS base URL was not resolved from the token's services claim", + BaseUrlUnresolved, + ) + })?; + + // Request bodies can reference key-material contexts; wipe on drop. + let body = Zeroizing::new( + serde_json::to_vec(&request) + .map_err(|e| ViturRequestError::prepare("Failed to serialize request", e))?, + ); + // The bearer token is a credential; wipe the header buffer on drop. + let auth = Zeroizing::new(format!("Bearer {access_token}")); + let headers = Zeroizing::new(request_headers(auth.as_str())); + + // The shared import reclaims both response slots before judging + // either, and the body it hands back wipes on drop (it carries + // wrapped key material). + let response = transport::send("POST", url.as_str(), &headers, &body) + .map_err(|e| ViturRequestError::parse("Host response buffer failed validation", e))?; + + let content_type = header_value(&response.headers, "content-type"); + map_response(response.status, content_type, &response.body) + } +} + +/// An [`AuthStrategy`] that fetches the bearer token from the host on every +/// request via `token_get`. Refresh policy stays host-side (Phase-1 auth): +/// whatever token the host hands over is presented as-is, so the host can +/// rotate tokens without re-initialising the cipher. +pub struct HostTokenStrategy; + +impl AuthStrategy for &HostTokenStrategy { + async fn get_token(self) -> Result<ServiceToken, AuthError> { + let mut token_ptr: u32 = 0; + let mut token_len: u32 = 0; + // SAFETY: the out-slots are stack locals the host writes once. + let status = unsafe { token_get(&mut token_ptr, &mut token_len) }; + // Reclaim before judging the status: a host that allocated the token + // buffer *and then* reported a failure would otherwise leave a live + // credential registered, unfreed and unwiped. + // + // SAFETY: the pointer comes from the host's `se_alloc` call; the + // registry validates it before any Vec is rebuilt. + let bytes = + unsafe { buffers::take(token_ptr as *mut u8, token_len as usize) }.map(Zeroizing::new); + if status != 0 { + return Err(AuthError::Custom(CustomError(format!( + "host token_get failed with status {status}" + )))); + } + let bytes = bytes.ok_or_else(|| { + AuthError::Custom(CustomError( + "host token buffer failed validation".to_string(), + )) + })?; + let text = std::str::from_utf8(&bytes) + .map_err(|_| AuthError::Custom(CustomError("host token is not UTF-8".to_string())))? + // A host that read the token from a file or a subprocess hands it + // over with the trailing newline still attached; left in place it + // would break the `name: value\n` header buffer in `send`, and no + // bearer token has meaningful surrounding whitespace anyway. + .trim(); + if text.is_empty() { + return Err(AuthError::Custom(CustomError( + "host token is empty".to_string(), + ))); + } + // Trim handles the *surrounding* whitespace case above; an *interior* + // control character would survive it and land in the `name: value\n` + // header buffer, where a newline splits the authorization line in + // two — header injection into the host transport (or a silently + // truncated credential and a confusing 401). No bearer token contains + // control characters, so reject rather than sanitise. + if text.chars().any(char::is_control) { + return Err(AuthError::Custom(CustomError( + "host token contains control characters".to_string(), + ))); + } + // `SecretToken` wipes on drop; `bytes` (the only other copy) wipes + // via its `Zeroizing` wrapper above. + Ok(ServiceToken::new(SecretToken::new(text))) + } +} diff --git a/languages/golang/stackencrypt/guest/src/lib.rs b/languages/golang/stackencrypt/guest/src/lib.rs new file mode 100644 index 000000000..93f6aa365 --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/lib.rs @@ -0,0 +1,90 @@ +// Security lints — the block `stack-encrypt` and `stack-auth` carry, minus +// `deny(unsafe_code)`: the export surface (`abi`) and the token import +// (`host`) are `extern "C"` over raw pointers by nature. Every `unsafe` +// block is confined to those two wasm32-only modules and documented at the +// site; `unsafe_op_in_unsafe_fn` keeps each one explicit. The allocator, +// the buffer registry and the transport import are `stack-guest-abi`'s. +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +// Prevent mem::forget from bypassing ZeroizeOnDrop +#![warn(clippy::mem_forget)] +// Prevent accidental data leaks via output +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Relax in tests +#![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))] +// The crate's target is wasm32; `abi` and `host` only exist there, so on a +// native doc build their intra-doc links have nothing to resolve to. The +// wasm32 doc build (`mise run wasm:guest:test`) is where links are enforced. +#![cfg_attr(not(target_arch = "wasm32"), allow(rustdoc::broken_intra_doc_links))] +//! # stack-encrypt WASI guest +//! +//! WASI guest module exposing [`stack-encrypt`](stack_encrypt) — +//! ZeroKMS-backed AEAD over structured values, SEM index terms, batched +//! records — to non-Rust hosts. Built for `wasm32-wasip1` and embedded by +//! the Go module in the parent directory (wazero host, `CGO_ENABLED=0`). +//! Phase 3 of `docs/plans/stack-encrypt-go-bindings.md`. +//! +//! Control stays in Rust: request assembly, key derivation, batching, and +//! AAD/PRF context binding run unmodified inside the guest. The host +//! provides exactly two imports (HTTP transport and the bearer token — see +//! [`host`]); what crosses the boundary per call is a value tree in, a +//! ciphertext/record tree out, and — inside the call — the same bytes that +//! would cross TLS anyway. The client key enters guest memory once at +//! `se_cipher_init`; derived data keys and index keys never leave. +//! +//! One instance is one client: `se_cipher_init` runs once per instance and +//! the keysets that client uses are selected per call through the options +//! object ([`options`]), loaded on first use. There is no cipher handle, +//! and nothing for the host to allocate, alias or free — `se_shutdown` is +//! the one lifetime call, and it exists because closing a wasm instance +//! frees linear memory without running Rust destructors. +//! +//! Split into: +//! +//! - [`ops`], [`options`], [`config`], [`response`], [`headers`], +//! [`status`] — everything that is pure logic over +//! `StackCipher<K>` / `KeysetCipher<K>` / bytes. Compiles and unit-tests +//! on the native host target (`cargo test` here, no wasm toolchain +//! needed) against `stack_kms::FakeDataKeySource`. +//! - [`abi`], [`host`] (wasm32 only) — this guest's export surface and its +//! token import. The conventions every guest shares — `se_alloc` / +//! `se_dealloc`, the buffer registry, the packed result encoding, the +//! status table, the `transport_send` import — are `stack_guest_abi`'s; +//! [`abi`]'s module docs give this guest's contract on top of them. +//! +//! On wasm32 `vitaminc-encrypt` uses its pure-Rust (RustCrypto `aes-gcm`) +//! backend; the trade-offs are documented there. Values cross the boundary +//! in the vitaminc FFI codec (`vitaminc_aead_value::transport` — an FFI +//! encoding, not a storage format); the leaves inside a ciphertext tree are +//! the *frozen* `SealedValue` byte encoding from Phase 2, so a leaf lifted +//! out of a tree is exactly what a database column holds. + +pub mod config; +pub mod headers; +pub mod ops; +pub mod options; +pub mod response; +pub mod status; + +// The ABI's packed u64 results embed 32-bit pointers, its bounds checks +// read the wasm linear-memory size, and `host` calls imported functions — +// so these modules only exist on wasm32. A native cdylib build therefore +// exports no se_* symbols at all — failing loudly at symbol lookup — +// instead of exporting a silently wrong ABI (the `ptr << 32` packing would +// truncate a 64-bit pointer). +#[cfg(target_arch = "wasm32")] +pub mod abi; +#[cfg(target_arch = "wasm32")] +pub mod host; diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs new file mode 100644 index 000000000..da2dce0e1 --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -0,0 +1,533 @@ +//! The guest's operations, written against `StackCipher<K>` / `KeysetCipher<K>` for any +//! [`DataKeySource`] so they compile — and their tests run — on the native +//! host target with `FakeDataKeySource`. The wasm32-only [`crate::abi`] +//! module wires them to the session table and the packed ABI; nothing in +//! here knows about linear memory. +//! +//! Values and ciphertext trees cross the boundary in the vitaminc FFI codec +//! (`vitaminc_aead_value::transport`) — the same codec the vitaminc guest +//! uses, so the Go side carries exactly one codec. A ciphertext tree's +//! leaves are re-encoded through [`SealedValue::to_bytes`] / +//! [`SealedValue::from_bytes`]: the codec sees an opaque byte-string leaf, +//! and the bytes inside it are the frozen storage encoding a database column +//! holds — a leaf lifted out of a tree here can be written to Postgres +//! as-is, and vice versa. +//! +//! # Errors +//! +//! Every function reports a [`crate::status`] code, never a message: these +//! are attacker-reachable decode/decrypt paths, and the status codes leak +//! only the failure class (see `status.rs`). +//! +//! # What is here, and what is not +//! +//! The operations themselves live in [`stack_encrypt::dynamic`]: reading a +//! context out of a value, dispatching an index term on a value's variant, +//! and driving a record plan. That is shared with every other language +//! binding, because none of it is specific to Go or to wasm. +//! +//! What is left here is what genuinely is this guest's: the codec both +//! directions, buffers sized before a byte of plaintext is written, the +//! ABI's numeric term kinds, and the mapping from a library error to a +//! status code. + +use stack_encrypt::dynamic::{self, Scalar, Scope, TermKind}; +use stack_encrypt::{ + BoxedPassthrough, CipherText, Element, Encrypt, KeysetCipher, SealedValue, StackCipherText, +}; +use stack_kms::DataKeySource; +use vitaminc_aead_value::{transport as codec, FfiValue}; +use vitaminc_protected::Controlled; + +use crate::status::{status_for_dynamic, status_for_error, STATUS_ENCODING, STATUS_INTERNAL}; + +/// Term kinds for `se_term`, part of the guest/host contract (the Go host +/// mirrors these values). +pub const TERM_EQUALITY: u32 = 1; +/// See [`TERM_EQUALITY`]. +pub const TERM_MATCH: u32 = 2; +/// See [`TERM_EQUALITY`]. +pub const TERM_ORE: u32 = 3; +/// See [`TERM_EQUALITY`]. +pub const TERM_OPE: u32 = 4; + +/// A ciphertext tree whose leaves are the frozen [`SealedValue`] byte +/// encoding — the shape that crosses the FFI codec. +type BytesTree = CipherText<Vec<u8>, BoxedPassthrough>; + +// ============================================================================= +// Whole-value encrypt / decrypt (the vitaminc guest's vc_encrypt shape) +// ============================================================================= + +/// Encrypt a codec-encoded [`FfiValue`] tree under `aad`, sealing every leaf +/// against a fresh ZeroKMS data key (one batched request; see the module +/// docs for how a batch is chunked). With `as_element`, +/// seal it as a *sequence element* — interchangeable with rows written by +/// encrypting a whole sequence under the same AAD. +/// +/// This is the cipher-directed path, and it takes the AAD as `StackCipher` +/// does: any bytes, including none. An empty `aad` seals under no context — +/// the plain AEAD use `Aes256Cipher` allows, opened symmetrically by +/// [`decrypt_value`] — and is the Go caller's choice to make. The record and +/// term paths ([`encrypt_record`], [`decrypt_record`], [`term`]) are the +/// ones that bind fields: each takes a [`NonEmpty`](stack_encrypt::NonEmpty) context, proven once at +/// the boundary when the plan or the term's context is parsed, and refused +/// as [`STATUS_ENCODING`] when empty. +pub async fn encrypt_value<K>( + cipher: &KeysetCipher<'_, K>, + value: &[u8], + aad: &[u8], + as_element: bool, +) -> Result<Vec<u8>, u32> +where + K: DataKeySource + Sync, +{ + let value = decode_value(value)?; + let tree = if as_element { + Element(value).encrypt_with_aad(cipher, aad) + } else { + value.encrypt_with_aad(cipher, aad) + } + .map_err(|_| STATUS_INTERNAL)?; + let ct = tree + .seal(cipher, aad) + .await + .map_err(|e| status_for_error(&e))?; + encode_tree(ct) +} + +/// Decrypt a codec-encoded ciphertext tree back into a codec-encoded +/// [`FfiValue`] tree. The output buffer contains plaintext — the ABI +/// layer's ownership rules govern its wiping. +/// +/// One batched `retrieve_keys` per invocation, dispatched as one ZeroKMS +/// call per 500 keyed leaves and, under [`Scope::Client`], per keyset the +/// tree's leaves were sealed under — the same rule [`decrypt_record`] +/// states. A tree small enough and single-keyset enough is the one request +/// that suggests; nothing here promises it in general. +/// +/// Symmetric with [`encrypt_value`]: the AAD is whatever the value was sealed +/// under, empty included. The [`Scope`] says which keysets may be opened: +/// any, or one, refusing the rest before any key is retrieved. +pub async fn decrypt_value<K>( + scope: Scope<'_, K>, + ciphertext: &[u8], + aad: &[u8], + as_element: bool, +) -> Result<Vec<u8>, u32> +where + K: DataKeySource + Sync, +{ + let tree = decode_tree(ciphertext)?; + // One `decrypt` per arm, not one `decipher` and two drives. The element + // derivation is `Element<T>`'s to apply and naming the type is what asks + // for it; the scope decides whether a foreign leaf is refused before any + // key is retrieved. Only one arm runs, so the retrieve happens once. + let value: FfiValue = match (&scope, as_element) { + (Scope::Client(cipher), true) => cipher + .decrypt::<Element<FfiValue>, _>(tree, aad) + .await + .map(Element::into_inner), + (Scope::Client(cipher), false) => cipher.decrypt(tree, aad).await, + (Scope::Keyset(keyset), true) => keyset + .decrypt::<Element<FfiValue>, _>(tree, aad) + .await + .map(Element::into_inner), + (Scope::Keyset(keyset), false) => keyset.decrypt(tree, aad).await, + } + .map_err(|e| status_for_error(&e))?; + encode_value(value) +} + +// ============================================================================= +// Terms +// ============================================================================= + +/// Derive one index term: a codec-encoded scalar and a codec-encoded +/// context in, the term's frozen byte encoding out (see `stack-encrypt`'s +/// `sem` module docs). Under the local HMAC backend the derivation is one +/// PRF/CLLW computation with no ZeroKMS I/O; that is the backend's +/// property, not this operation's contract — the term API is a `Pending` +/// so a backend that derives terms at ZeroKMS settles the same way. +/// +/// The context is one part — a string, bytes, or an `i32`/`i64`/`u32`/`u64` +/// — or an array of parts, nested as deep as the transport codec allows +/// ([`codec::MAX_DEPTH`] levels from the root of the encoded value; deeper +/// is [`STATUS_ENCODING`] before the context is parsed), exactly as a plan +/// field's; [`dynamic::context`] is the one home of that grammar. Shape is identity: +/// `[x]` is a PAE-framed list and `x` is not, so a probe takes the context +/// in the shape the field was sealed under — a plan field's context +/// verbatim, a bare part for a Rust leaf sealed under that part, and the +/// same parts as a (left-nested) list for a Rust row sealed under an +/// extended context. +pub async fn term<K>( + cipher: &KeysetCipher<'_, K>, + value: &[u8], + context: &[u8], + kind: u32, +) -> Result<Vec<u8>, u32> +where + K: DataKeySource + Sync, +{ + // The same proof every stack-encrypt leaf demands: an empty context is + // `STATUS_ENCODING` here, before any derivation. + let context = dynamic::context(decode_value(context)?).map_err(|e| status_for_dynamic(&e))?; + let (scalar, kind) = parse_term(decode_value(value)?, kind)?; + dynamic::term(cipher, scalar, kind, context) + .await + .map_err(|e| status_for_dynamic(&e)) +} + +/// The static half of a term: the kind is one of the ABI's table, the value +/// is a scalar, and the scheme defines the pair +/// ([`TermKind::supports`]). Shared by [`term`] and [`validate::term`] so +/// the ABI refuses exactly what the operation would, before any keyset is +/// resolved. +fn parse_term(value: FfiValue, kind: u32) -> Result<(Scalar, TermKind), u32> { + let kind = match kind { + TERM_EQUALITY => TermKind::Equality, + TERM_MATCH => TermKind::Match, + TERM_ORE => TermKind::Ore, + TERM_OPE => TermKind::Ope, + _ => return Err(STATUS_ENCODING), + }; + let scalar = Scalar::of(&value, kind).map_err(|e| status_for_dynamic(&e))?; + if !kind.supports(&scalar) { + return Err(STATUS_ENCODING); + } + Ok((scalar, kind)) +} + +// ============================================================================= +// Records +// ============================================================================= + +/// Encrypt a record — or a batch of records — per a plan. +/// +/// Both arguments are codec-encoded: the plan is the object +/// [`dynamic::record::plan`] parses, the source an object of +/// `{ field: scalar }` (one record) or an array of them (a batch). The +/// result is a codec-encoded ciphertext tree — per record a map of +/// `field → { output-key → node }`. +/// +/// All rows and fields seal in one batched `generate_keys`; that batch +/// reaches ZeroKMS as one request per +/// [`ClientOpts::max_keys_per_req`](stack_kms::ClientOpts::with_max_keys_per_req) +/// keyed leaves (500 by default, sent sequentially: the guest pins +/// `max_concurrent_reqs` to 1), so "one call" is exact up to 500 leaves and +/// "one call per 500" past it. Everything else about the shape — the plan +/// grammar, the one-context rule, why terms ride as passthrough — is +/// [`dynamic::record`]'s to state. +pub async fn encrypt_record<K>( + cipher: &KeysetCipher<'_, K>, + source: &[u8], + plan: &[u8], +) -> Result<Vec<u8>, u32> +where + K: DataKeySource + Sync, +{ + let plan = dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?; + let tree = dynamic::record::encrypt(cipher, decode_value(source)?, &plan) + .await + .map_err(|e| status_for_dynamic(&e))?; + encode_tree(tree) +} + +/// Decrypt a record — or a batch — produced by [`encrypt_record`] under the +/// same plan. Only the `"c"` outputs participate (terms are one-way). +/// +/// One batched `retrieve_keys` per invocation, dispatched as one ZeroKMS +/// call per 500 keyed leaves and, under [`Scope::Client`], per keyset the +/// leaves were sealed under. The output buffer contains plaintext — the ABI +/// layer's ownership rules govern its wiping. +pub async fn decrypt_record<K>( + scope: Scope<'_, K>, + record: &[u8], + plan: &[u8], +) -> Result<Vec<u8>, u32> +where + K: DataKeySource + Sync + 'static, +{ + let plan = dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?; + let value = dynamic::record::decrypt(scope, decode_tree(record)?, &plan) + .await + .map_err(|e| status_for_dynamic(&e))?; + encode_value(value) +} + +// ============================================================================= +// Boundary validation +// ============================================================================= + +/// The static checks the ABI runs on every operation input *before* it +/// consults the cipher, so a malformed call is [`STATUS_ENCODING`] whether +/// or not the instance is initialised, and never costs a keyset load. Each +/// runs the same parser the operation itself runs — `parse_term`, +/// [`dynamic::record::check_source`], [`dynamic::record::check_record`] — +/// so the two cannot disagree on what is malformed; the second pass is +/// cheap next to the AEAD and buys a stable status precedence. +pub mod validate { + use super::*; + + /// A codec-encoded value tree decodes. + pub fn value(bytes: &[u8]) -> Result<(), u32> { + decode_value(bytes).map(drop) + } + + /// A codec-encoded ciphertext tree decodes and its leaves are + /// well-formed `SealedValue` encodings. + pub fn tree(bytes: &[u8]) -> Result<(), u32> { + decode_tree(bytes).map(drop) + } + + /// A term's inputs, as [`term`] takes them: the context decodes and is + /// non-empty, the kind is one of [`TERM_EQUALITY`] .. [`TERM_OPE`], and + /// the value is a scalar the scheme defines that term for + /// ([`TermKind::supports`]). + pub fn term(value: &[u8], context: &[u8], kind: u32) -> Result<(), u32> { + dynamic::context(decode_value(context)?) + .map(drop) + .map_err(|e| status_for_dynamic(&e))?; + parse_term(decode_value(value)?, kind).map(drop) + } + + /// A record source against its plan, as [`encrypt_record`] takes them: + /// the plan decodes and parses (every field's context non-empty, every + /// output known), and the source fits it (shape, field set, each value + /// against its field's outputs). + pub fn record(source: &[u8], plan: &[u8]) -> Result<(), u32> { + let plan = + dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?; + dynamic::record::check_source(decode_value(source)?, &plan) + .map_err(|e| status_for_dynamic(&e)) + } + + /// A record tree against its plan, as [`decrypt_record`] takes them: + /// the plan parses, the tree decodes with well-formed leaves, and every + /// ciphertext-bearing field has a `"c"` node that is not a passthrough. + pub fn record_tree(record: &[u8], plan: &[u8]) -> Result<(), u32> { + let plan = + dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?; + dynamic::record::check_record(decode_tree(record)?, &plan) + .map_err(|e| status_for_dynamic(&e)) + } +} + +// ============================================================================= +// Codec glue +// ============================================================================= + +fn decode_value(bytes: &[u8]) -> Result<FfiValue, u32> { + codec::decode_value(&mut codec::Reader::new(bytes)).map_err(|_| STATUS_ENCODING) +} + +/// Encode a value tree into a buffer sized **before** the first byte is +/// written. +/// +/// This buffer is plaintext on the decrypt path, and a `Vec` grown by the +/// codec's pushes would leave partial plaintext in every abandoned +/// allocation a reallocation could not extend in place — memory nothing +/// wipes, undercutting the guarantee the registry makes about the buffer it +/// eventually hands the host. Reserving the exact encoded length up front +/// means the encoder never reallocates, and `register`'s `into_boxed_slice` +/// (capacity == length) does not copy either. +fn encode_value(value: FfiValue) -> Result<Vec<u8>, u32> { + let mut out = exact_buffer(value_encoded_len(&value))?; + codec::encode_value(value, &mut out).map_err(|_| STATUS_ENCODING)?; + Ok(out) +} + +fn decode_tree(bytes: &[u8]) -> Result<StackCipherText, u32> { + let tree: BytesTree = codec::decode_ciphertext_boxed(&mut codec::Reader::new(bytes)) + .map_err(|_| STATUS_ENCODING)?; + // Structural only — a decoded leaf proves nothing until its AEAD opens + // (see the `SealedValue` docs). + map_leaves(tree, &mut |l: Vec<u8>| { + SealedValue::from_bytes(&l).map_err(|_| STATUS_ENCODING) + }) +} + +/// The encode twin of [`decode_tree`]. Passthrough nodes can carry caller +/// plaintext, so this is sized up front for the same reason +/// [`encode_value`] is. +fn encode_tree(tree: StackCipherText) -> Result<Vec<u8>, u32> { + let tree = map_leaves(tree, &mut |l: SealedValue| Ok::<_, u32>(l.to_bytes()))?; + let mut out = exact_buffer(tree_encoded_len(&tree))?; + codec::encode_ciphertext_boxed(tree, &mut out).map_err(|_| STATUS_ENCODING)?; + Ok(out) +} + +/// Rebuild a ciphertext tree with every leaf run through `leaf`, keeping the +/// structure (and the passthrough payloads) untouched. One definition for +/// both directions of the frozen [`SealedValue`] leaf encoding. +fn map_leaves<A, B, E>( + tree: CipherText<A, BoxedPassthrough>, + leaf: &mut impl FnMut(A) -> Result<B, E>, +) -> Result<CipherText<B, BoxedPassthrough>, E> { + Ok(match tree { + CipherText::Single(l) => CipherText::Single(leaf(l)?), + CipherText::None(l) => CipherText::None(leaf(l)?), + CipherText::EmptySequence(l) => CipherText::EmptySequence(leaf(l)?), + CipherText::EmptyMap(l) => CipherText::EmptyMap(leaf(l)?), + CipherText::Sequence(items) => CipherText::Sequence( + items + .into_iter() + .map(|item| map_leaves(item, leaf)) + .collect::<Result<_, E>>()?, + ), + CipherText::Map(entries) => CipherText::Map( + entries + .into_iter() + .map(|(k, v)| Ok((k, map_leaves(v, leaf)?))) + .collect::<Result<_, E>>()?, + ), + CipherText::Passthrough(p) => CipherText::Passthrough(p), + }) +} + +/// A buffer with exactly `len` bytes of capacity, or [`STATUS_ENCODING`] if +/// the length could not be computed (an encoding the codec would refuse +/// anyway) or [`STATUS_INTERNAL`] if the allocation failed. `try_reserve_exact` +/// rather than `reserve`: on wasm32 an oversized request must be a status, +/// not an abort that poisons the instance — and the *exact* variant so that +/// `register`'s `into_boxed_slice` finds capacity already equal to length +/// and does not shrink-to-fit (a shrink that moved would free the filled +/// block without wiping it, which is the whole hazard this avoids). +fn exact_buffer(len: Option<usize>) -> Result<Vec<u8>, u32> { + let len = len.ok_or(STATUS_ENCODING)?; + let mut out = Vec::new(); + out.try_reserve_exact(len).map_err(|_| STATUS_INTERNAL)?; + Ok(out) +} + +/// Exact byte length of the codec's encoding of `value`. `None` on overflow +/// or on a length the codec's `u32` frames cannot express — the encode would +/// fail on those anyway, so the caller reports an encoding error. +fn value_encoded_len(value: &FfiValue) -> Option<usize> { + // tag byte + fixed payload, or tag + u32 length prefix + payload. + let framed = |len: usize| u32::try_from(len).ok().and_then(|_| len.checked_add(5)); + match value { + FfiValue::Null | FfiValue::Undefined | FfiValue::Bool(_) => Some(1), + FfiValue::Int32(_) | FfiValue::UInt32(_) | FfiValue::Float32(_) => Some(5), + FfiValue::Int64(_) | FfiValue::UInt64(_) | FfiValue::Float64(_) => Some(9), + FfiValue::String(s) => framed(s.risky_ref().len()), + FfiValue::Bytes(b) => framed(b.risky_ref().len()), + FfiValue::Array(items) => items.iter().try_fold(5usize, |acc, item| { + acc.checked_add(value_encoded_len(item)?) + }), + FfiValue::Object(entries) => entries.iter().try_fold(5usize, |acc, (key, value)| { + acc.checked_add(framed(key.len())?.checked_sub(1)?)? + .checked_add(value_encoded_len(value)?) + }), + FfiValue::Passthrough(inner) => value_encoded_len(inner)?.checked_add(1), + } +} + +/// Exact byte length of the codec's encoding of a ciphertext tree. The +/// passthrough payloads are read (not consumed) through `Any::downcast_ref`, +/// matching what `encode_ciphertext_boxed` will re-home them to; a payload +/// that is not an [`FfiValue`] is `None`, which is the same rejection the +/// encoder would make. +fn tree_encoded_len(tree: &BytesTree) -> Option<usize> { + let framed = |len: usize| u32::try_from(len).ok().and_then(|_| len.checked_add(5)); + match tree { + CipherText::Single(l) + | CipherText::None(l) + | CipherText::EmptySequence(l) + | CipherText::EmptyMap(l) => framed(l.len()), + CipherText::Sequence(items) => items + .iter() + .try_fold(5usize, |acc, item| acc.checked_add(tree_encoded_len(item)?)), + CipherText::Map(entries) => entries.iter().try_fold(5usize, |acc, (key, value)| { + acc.checked_add(framed(key.len())?.checked_sub(1)?)? + .checked_add(tree_encoded_len(value)?) + }), + CipherText::Passthrough(p) => { + value_encoded_len((**p).downcast_ref::<FfiValue>()?)?.checked_add(1) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vitaminc_protected::Protected; + + // `value_encoded_len` / `tree_encoded_len` re-derive the codec's framing + // arithmetic; the codec exports no `encoded_len` of its own, so these + // pins are the only thing that fails if the two drift. Drift is not a + // cosmetic bug: an undersized reservation makes `encode_value` + // reallocate mid-encode, leaving unwiped partial plaintext in the + // abandoned allocation — silently. + + fn every_value_shape() -> Vec<FfiValue> { + vec![ + FfiValue::Null, + FfiValue::Undefined, + FfiValue::Bool(true), + FfiValue::Int32(-5), + FfiValue::UInt32(5), + FfiValue::Float32(1.5), + FfiValue::Int64(-9), + FfiValue::UInt64(9), + FfiValue::Float64(2.5), + FfiValue::String("".into()), + FfiValue::String("héllo".into()), + FfiValue::Bytes(Protected::new(Vec::new())), + FfiValue::Bytes(Protected::new(vec![0u8; 300])), + FfiValue::Array(Vec::new()), + FfiValue::Array(vec![FfiValue::Bool(false), FfiValue::String("x".into())]), + FfiValue::Object(Vec::new()), + FfiValue::Object(vec![ + ("a".to_string(), FfiValue::Int32(1)), + ( + "nested".to_string(), + FfiValue::Object(vec![("b".to_string(), FfiValue::Null)]), + ), + ]), + FfiValue::Passthrough(Box::new(FfiValue::Int64(7))), + FfiValue::Passthrough(Box::new(FfiValue::Array(vec![FfiValue::String( + "deep".into(), + )]))), + ] + } + + #[test] + fn value_encoded_len_matches_the_codec_exactly() { + for (i, value) in every_value_shape().into_iter().enumerate() { + let expected = value_encoded_len(&value).expect("encodable shape"); + let mut out = Vec::new(); + codec::encode_value(value, &mut out).expect("codec encode"); + assert_eq!(out.len(), expected, "shape {i}"); + } + } + + #[test] + fn tree_encoded_len_matches_the_codec_exactly() { + let leaf = |bytes: &[u8]| -> BytesTree { CipherText::Single(bytes.to_vec()) }; + let trees: Vec<BytesTree> = vec![ + leaf(b""), + leaf(&[7u8; 40]), + CipherText::None(vec![1, 2]), + CipherText::EmptySequence(vec![3]), + CipherText::EmptyMap(Vec::new()), + CipherText::Sequence(vec![leaf(b"a"), CipherText::None(vec![9])]), + CipherText::Map(vec![ + ("name".to_string(), leaf(b"ct")), + ( + "inner".to_string(), + CipherText::Map(vec![("x".to_string(), leaf(b"y"))]), + ), + ]), + CipherText::Passthrough( + Box::new(FfiValue::Bytes(Protected::new(vec![1, 2, 3]))) as BoxedPassthrough + ), + ]; + for (i, tree) in trees.into_iter().enumerate() { + let expected = tree_encoded_len(&tree).expect("encodable shape"); + let mut out = Vec::new(); + codec::encode_ciphertext_boxed(tree, &mut out).expect("codec encode"); + assert_eq!(out.len(), expected, "tree {i}"); + } + } +} diff --git a/languages/golang/stackencrypt/guest/src/options.rs b/languages/golang/stackencrypt/guest/src/options.rs new file mode 100644 index 000000000..3fc9b6727 --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/options.rs @@ -0,0 +1,311 @@ +//! The per-call options object, and the keyset selector it carries. +//! +//! Every export that touches a keyset takes one more codec-encoded +//! argument: an [`FfiValue::Object`] with exactly one key, `keyset`, whose +//! value is a tagged object naming the keyset the call binds to: +//! +//! | selector | meaning | +//! |-------------------|---------| +//! | `{"default": {}}` | the cipher's default keyset (the one named at `se_cipher_init`, else the client's) | +//! | `{"name": <string>}` | the keyset with that name, loaded on first use | +//! | `{"id": <16 bytes>}` | the keyset with that id (raw UUID bytes), loaded on first use | +//! | `{"any": {}}` | **decrypt only**: open leaves from whichever keyset each was sealed under, one batched retrieval per keyset (chunked at the client's request limit, 500 keys) | +//! +//! Every variant is spelled; there is no zero-length or omitted-field +//! sentinel, so a host that means the default says so. A name is validated +//! at parse (ZeroKMS's own rules for keyset names), so a malformed +//! selector is refused before the cipher is consulted. On the sealing and +//! term exports the selector picks the keyset that mints; on the opening +//! exports it is a *constraint*: `{"name"}`, `{"id"}` and `{"default"}` +//! refuse a leaf sealed under any other keyset before any key is retrieved +//! ([`STATUS_FOREIGN_KEYSET`](crate::status::STATUS_FOREIGN_KEYSET)), and `{"any"}` lifts the constraint. `{"any"}` +//! on a sealing or term export is [`STATUS_ENCODING`]: there is no keyset +//! to mint under. Anything else — another key, a second key, a wrong value +//! type, an id that is not 16 bytes — is [`STATUS_ENCODING`]. +//! +//! This object is a cross-language contract: every binding builds it, so +//! it is objects, strings, bytes and nothing else, and this module is its +//! one home. The Go bindings plan points here. + +use stack_encrypt::dynamic::Scope; +use stack_encrypt::{KeysetCipher, StackCipher}; +use stack_kms::{IdentifiedBy, IndexKeySource}; +use uuid::Uuid; +use vitaminc_aead_value::FfiValue; +use vitaminc_protected::Controlled; +use zerokms_protocol::Name; + +use crate::status::{status_for_error, STATUS_ENCODING}; + +/// Which keyset a call binds to. See the [module docs](self). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeysetSelector { + /// The cipher's default keyset. + Default, + /// A keyset by name, already validated against ZeroKMS's naming rules. + Name(Name), + /// A keyset by id. + Id(Uuid), + /// Whichever keyset each leaf was sealed under; opening only. + Any, +} + +/// Which side of the boundary an options object is parsed for: the sealing +/// and term exports need a keyset to mint under, so `{"any"}` is refused +/// there. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + /// `se_encrypt`, `se_encrypt_element`, `se_encrypt_record`, `se_term`. + Mint, + /// `se_decrypt`, `se_decrypt_element`, `se_decrypt_record`. + Open, +} + +/// The parsed options object. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Options { + pub keyset: KeysetSelector, +} + +/// Parse a decoded options object for `side`. Anything outside the shape in +/// the [module docs](self) is [`STATUS_ENCODING`]. +pub fn parse_options(value: FfiValue, side: Side) -> Result<Options, u32> { + let FfiValue::Object(entries) = value else { + return Err(STATUS_ENCODING); + }; + let mut keyset: Option<KeysetSelector> = None; + for (key, value) in entries { + match key.as_str() { + "keyset" if keyset.is_none() => keyset = Some(parse_selector(value)?), + _ => return Err(STATUS_ENCODING), + } + } + let keyset = keyset.ok_or(STATUS_ENCODING)?; + if side == Side::Mint && keyset == KeysetSelector::Any { + return Err(STATUS_ENCODING); + } + Ok(Options { keyset }) +} + +/// Parse a keyset selector: a tagged object with exactly one key. Spelled +/// out here rather than in [`parse_options`] so `se_keyset`, which takes a +/// bare selector, shares the one definition. +pub fn parse_selector(value: FfiValue) -> Result<KeysetSelector, u32> { + let FfiValue::Object(mut entries) = value else { + return Err(STATUS_ENCODING); + }; + if entries.len() != 1 { + return Err(STATUS_ENCODING); + } + let (tag, value) = entries.pop().ok_or(STATUS_ENCODING)?; + Ok(match (tag.as_str(), value) { + ("default", FfiValue::Object(fields)) if fields.is_empty() => KeysetSelector::Default, + ("any", FfiValue::Object(fields)) if fields.is_empty() => KeysetSelector::Any, + ("name", FfiValue::String(name)) => { + // Valid UTF-8 by `Utf8String`'s construction invariant; checked + // rather than assumed because this is boundary code. A keyset + // name is not secret, so the payload moves out of its + // `Protected` rather than being copied and wiped. ZeroKMS's + // naming rules apply here, at the boundary, not at resolution. + let name = + String::from_utf8(name.into_inner().risky_unwrap()).map_err(|_| STATUS_ENCODING)?; + KeysetSelector::Name(Name::try_from(name.as_str()).map_err(|_| STATUS_ENCODING)?) + } + ("id", FfiValue::Bytes(bytes)) => { + KeysetSelector::Id(Uuid::from_slice(bytes.risky_ref()).map_err(|_| STATUS_ENCODING)?) + } + _ => return Err(STATUS_ENCODING), + }) +} + +impl KeysetSelector { + /// The keyset this selector names, as the cipher resolves it: the + /// default without a round trip, a name or id through the cipher's + /// cache (a first use is one `load-keyset` call). `Any` is not a keyset + /// and is [`STATUS_ENCODING`] here; opening exports resolve it through + /// [`scope_for`] instead. + pub async fn resolve<'c, K>( + &self, + cipher: &'c StackCipher<K>, + ) -> Result<KeysetCipher<'c, K>, u32> + where + K: IndexKeySource, + { + let by: IdentifiedBy = match self { + KeysetSelector::Default => return Ok(cipher.default_keyset()), + KeysetSelector::Any => return Err(STATUS_ENCODING), + KeysetSelector::Name(name) => IdentifiedBy::Name(name.clone()), + KeysetSelector::Id(id) => IdentifiedBy::Uuid(*id), + }; + cipher.keyset(by).await.map_err(|e| status_for_error(&e)) + } +} + +/// The [`Scope`] a decrypt-side selector names: the client for `{"any"}`, +/// which opens a leaf sealed under any of its keysets, or one keyset's +/// cipher, which opens only its own and refuses the rest before any key is +/// retrieved. +pub async fn scope_for<'c, K>( + cipher: &'c StackCipher<K>, + selector: &KeysetSelector, +) -> Result<Scope<'c, K>, u32> +where + K: IndexKeySource, +{ + match selector { + KeysetSelector::Any => Ok(Scope::Client(cipher)), + other => other.resolve(cipher).await.map(Scope::Keyset), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vitaminc_protected::Protected; + + fn obj(entries: Vec<(&str, FfiValue)>) -> FfiValue { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) + } + + fn options(selector: FfiValue) -> FfiValue { + obj(vec![("keyset", selector)]) + } + + fn empty() -> FfiValue { + FfiValue::Object(Vec::new()) + } + + #[test] + fn every_selector_variant_is_spelled() { + let id = Uuid::from_u128(7); + assert_eq!( + parse_selector(obj(vec![("default", empty())])), + Ok(KeysetSelector::Default) + ); + assert_eq!( + parse_selector(obj(vec![("any", empty())])), + Ok(KeysetSelector::Any) + ); + assert_eq!( + parse_selector(obj(vec![("name", FfiValue::String("acme".into()))])), + Ok(KeysetSelector::Name( + Name::try_from("acme").ok().expect("valid name") + )) + ); + assert_eq!( + parse_selector(obj(vec![( + "id", + FfiValue::Bytes(Protected::new(id.as_bytes().to_vec())) + )])), + Ok(KeysetSelector::Id(id)) + ); + } + + #[test] + fn a_selector_is_exactly_one_known_tag_with_the_right_payload() { + for (label, bad) in [ + ("an empty object", empty()), + ("a string", FfiValue::String("default".into())), + ("null", FfiValue::Null), + ("an unknown tag", obj(vec![("primary", empty())])), + ( + "two tags", + obj(vec![("default", empty()), ("any", empty())]), + ), + ( + "default with a payload", + obj(vec![("default", FfiValue::Bool(true))]), + ), + ( + "default with fields", + obj(vec![("default", obj(vec![("x", empty())]))]), + ), + ( + "a name that is not a string", + obj(vec![("name", FfiValue::UInt64(1))]), + ), + ( + "an empty name", + obj(vec![("name", FfiValue::String("".into()))]), + ), + ( + "a name past ZeroKMS's 64-byte limit", + obj(vec![( + "name", + FfiValue::String("x".repeat(65).as_str().into()), + )]), + ), + ( + "an id that is not 16 bytes", + obj(vec![("id", FfiValue::Bytes(Protected::new(vec![1, 2, 3])))]), + ), + ( + "an id as text", + obj(vec![( + "id", + FfiValue::String("00000000-0000-0000-0000-000000000007".into()), + )]), + ), + ] { + assert_eq!( + parse_selector(bad).err(), + Some(STATUS_ENCODING), + "{label} is not a selector and must be refused" + ); + } + } + + #[test] + fn options_are_one_keyset_key() { + assert_eq!( + parse_options(options(obj(vec![("default", empty())])), Side::Mint), + Ok(Options { + keyset: KeysetSelector::Default + }) + ); + for (label, bad) in [ + ("no keyset", empty()), + ("not an object", FfiValue::Null), + ( + "an unknown key beside it", + obj(vec![ + ("keyset", obj(vec![("default", empty())])), + ("mode", FfiValue::Bool(true)), + ]), + ), + ( + "keyset twice", + obj(vec![ + ("keyset", obj(vec![("default", empty())])), + ("keyset", obj(vec![("default", empty())])), + ]), + ), + ] { + assert_eq!( + parse_options(bad, Side::Open).err(), + Some(STATUS_ENCODING), + "{label} must be refused" + ); + } + } + + #[test] + fn any_is_an_opening_selector_only() { + let any = || options(obj(vec![("any", empty())])); + assert_eq!( + parse_options(any(), Side::Open), + Ok(Options { + keyset: KeysetSelector::Any + }) + ); + assert_eq!( + parse_options(any(), Side::Mint).err(), + Some(STATUS_ENCODING) + ); + } +} diff --git a/languages/golang/stackencrypt/guest/src/response.rs b/languages/golang/stackencrypt/guest/src/response.rs new file mode 100644 index 000000000..d784e5a5e --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/response.rs @@ -0,0 +1,154 @@ +//! The host transport's one piece of response logic that is *not* shared with +//! the reference `HttpConnection`: turning the import's `i32` return into +//! either a transport failure or an HTTP status. +//! +//! Everything past that — the 2xx content-type check, JSON deserialization, +//! and the 404/401/403/409 → [`ViturRequestErrorKind`] table — is +//! [`stack_kms::classify_response`], which lives outside the `http` feature +//! gate precisely so this guest and `HttpConnection` cannot drift apart. Kept +//! free of any wasm ABI concerns so it compiles — and its unit tests run — on +//! the native host target. +//! +//! [`ViturRequestErrorKind`]: zerokms_protocol::ViturRequestErrorKind + +use std::collections::HashMap; +use std::fmt; + +use serde::de::DeserializeOwned; +use stack_kms::classify_response; +use zerokms_protocol::ViturRequestError; + +/// The host reported it could not perform the HTTP call at all (negative +/// status). The body carries the host's error text. +#[derive(Debug)] +pub struct TransportFailure(pub String); + +impl fmt::Display for TransportFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "host transport failed: {}", self.0) + } +} + +impl std::error::Error for TransportFailure {} + +/// Map a host transport result onto the ZeroKMS protocol contract. +/// +/// `status` is the HTTP status code, or negative for a transport-level +/// failure (in which case `body` carries the host's error text). +/// `content_type` is the response Content-Type header, if any. +/// +/// A status outside the `u16` range is a host that is not honouring the +/// import contract; it is treated as a transport failure rather than being +/// truncated into some unrelated code. +pub fn map_response<T: DeserializeOwned>( + status: i32, + content_type: Option<&str>, + body: &[u8], +) -> Result<T, ViturRequestError> { + let Ok(status) = u16::try_from(status) else { + return Err(ViturRequestError::send( + "Host transport reported a failure", + TransportFailure(String::from_utf8_lossy(body).into_owned()), + )); + }; + // The guest does not carry the response headers into the error payloads: + // it has already read the only one it needs (content-type), and the rest + // would be an extra copy of attacker-influenced bytes for a Display + // string nothing reads. + classify_response(status, content_type, Some(body), HashMap::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use zerokms_protocol::{Keyset, ViturRequestErrorKind}; + + fn kind_of(result: Result<Vec<Keyset>, ViturRequestError>) -> ViturRequestErrorKind { + result.expect_err("expected an error").kind + } + + const KEYSETS_JSON: &[u8] = br#"[ + {"id":"6a70bd18-99ac-4650-b104-37eec3a15b09","name":"alpha","description":"","is_disabled":false,"is_default":true} + ]"#; + + #[test] + fn success_with_json_content_type_deserializes() { + let keysets: Vec<Keyset> = + map_response(200, Some("application/json"), KEYSETS_JSON).expect("deserializes"); + assert_eq!(keysets.len(), 1); + assert_eq!(keysets[0].name, "alpha"); + } + + #[test] + fn json_content_type_tolerates_parameters_and_case() { + // Same tolerance as the reference `HttpConnection` — literally the + // same predicate now. + let keysets: Vec<Keyset> = + map_response(200, Some("Application/JSON; charset=utf-8"), KEYSETS_JSON) + .expect("deserializes"); + assert_eq!(keysets.len(), 1); + } + + #[test] + fn success_without_json_content_type_is_parse_error() { + assert!(matches!( + kind_of(map_response(200, None, KEYSETS_JSON)), + ViturRequestErrorKind::ParseResponse + )); + // A proxy or load balancer answering 200 with an HTML error page. + assert!(matches!( + kind_of(map_response( + 200, + Some("text/html"), + b"<html>gateway error</html>" + )), + ViturRequestErrorKind::ParseResponse + )); + } + + #[test] + fn success_with_invalid_json_is_parse_error() { + assert!(matches!( + kind_of(map_response(200, Some("application/json"), b"not json")), + ViturRequestErrorKind::ParseResponse + )); + } + + #[test] + fn transport_failure_is_send_error() { + assert!(matches!( + kind_of(map_response(-1, None, b"connection refused")), + ViturRequestErrorKind::SendRequest + )); + // A status the import contract cannot mean is a transport failure + // too, never a truncated code. + assert!(matches!( + kind_of(map_response(70_000, None, b"nonsense")), + ViturRequestErrorKind::SendRequest + )); + } + + #[test] + fn error_statuses_map_to_their_kinds() { + assert!(matches!( + kind_of(map_response(401, None, b"nope")), + ViturRequestErrorKind::Unauthorized + )); + assert!(matches!( + kind_of(map_response(403, None, b"Not permitted")), + ViturRequestErrorKind::Forbidden + )); + assert!(matches!( + kind_of(map_response(404, None, b"missing")), + ViturRequestErrorKind::NotFound + )); + assert!(matches!( + kind_of(map_response(409, None, b"exists")), + ViturRequestErrorKind::Conflict + )); + assert!(matches!( + kind_of(map_response(500, None, b"boom")), + ViturRequestErrorKind::Other + )); + } +} diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs new file mode 100644 index 000000000..700c009ce --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -0,0 +1,364 @@ +//! The mapping from [`stack_encrypt::Error`] (and the dynamic and KMS +//! errors under it) onto the status table. +//! +//! The numbers themselves are [`stack_guest_abi::status`]'s — one table for +//! every guest, never renumbered, decoded once by the Go host — and are +//! re-exported here so this crate's modules and tests name them as they +//! always have. Defined outside the wasm32-gated ABI module so native builds +//! — the ops unit tests — can reference them too. +//! +//! What this guest decides is *which* number a given failure is. Codes 5–10 +//! map the ZeroKMS request outcomes ([`ViturRequestErrorKind`]-shaped) so a +//! Go caller can distinguish a bad token from a tampered ciphertext without +//! parsing strings; 11 is a term-derivation failure (a caller-input +//! condition, e.g. match text that yields no tokens); 12 is a keyset-scoped +//! open refusing a leaf whose keyset id is not the scope's — a host's own +//! constraint, checked before the leaf is authenticated and so not a +//! statement about tampering. +//! +//! # What each verdict means for this guest +//! +//! The shared table documents each code as the verdict a host acts on; +//! what follows is how this guest's exports arrive at them. +//! +//! - [`STATUS_AUTH`] is an AEAD open failure. Against ZeroKMS a wrong +//! context does not get that far — every data key is bound to its +//! context's descriptor, so the retrieve is refused first, as +//! [`STATUS_KMS_FORBIDDEN`]: that is the production form of a +//! wrong-context open. Only a key source that ignores descriptors (the +//! native tests' fake) reports a wrong context as `STATUS_AUTH`. +//! - [`STATUS_ENCODING`] covers, besides malformed transport bytes and a +//! bad pointer/length pair, a malformed config and an empty context on +//! the record and term exports. +//! - [`STATUS_KMS_UNAUTHORIZED`] is kept to a refused credential. A token +//! with no ZeroKMS `services` claim, or a host `token_get` that failed, +//! arrives through the auth strategy too but is [`STATUS_KMS_TRANSPORT`]: +//! no number of refreshes can fix it (`status_for_auth` draws the line). +//! - [`STATUS_KMS_TRANSPORT`] also covers an endpoint that could not be +//! resolved: no `zerokms_url` in the config *and* no ZeroKMS entry in the +//! token's `services` claim. +//! - [`STATUS_FOREIGN_KEYSET`] is an opening export constrained to one +//! keyset (`{"name"}`, `{"id"}` or `{"default"}` in its options) whose +//! leaf named another. A host that means "whichever keyset" opens with +//! `{"any"}`. The comparison reads the keyset id *out of the leaf*, before +//! anything is retrieved and so before anything is authenticated, which +//! means a flipped byte in that field arrives here exactly as a genuinely +//! misrouted row does. The id is bound into the leaf's context, so the +//! tampered leaf cannot go on to open — it fails as [`STATUS_AUTH`] — but +//! that verdict is only reached on the path where the constraint let it +//! through. Read this status as "not this keyset's row", never as "an +//! untampered row". + +use stack_auth::AuthError; +use stack_kms::{GenerateKeyError, LoadKeysetError, RetrieveKeyError}; +use zerokms_protocol::ViturRequestErrorKind; + +pub use stack_guest_abi::status::{ + STATUS_AUTH, STATUS_ENCODING, STATUS_FOREIGN_KEYSET, STATUS_INTERNAL, STATUS_KMS_CONFLICT, + STATUS_KMS_FORBIDDEN, STATUS_KMS_NOT_FOUND, STATUS_KMS_OTHER, STATUS_KMS_TRANSPORT, + STATUS_KMS_UNAUTHORIZED, STATUS_STATE, STATUS_TERM, +}; + +/// Map a sealing/opening error onto the ABI status word. +/// +/// Total over [`stack_encrypt::Error`] (which is `#[non_exhaustive]`, so the +/// catch-all arm is required as well as convenient): composition-bug variants +/// (`ResponseShape`, `KeysetMismatch`, `NoKeyset`, `KeyCountMismatch`) and +/// everything else unexpected collapse into [`STATUS_INTERNAL`] — statuses +/// distinguish what a host can act on, not what it can only log. +pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { + match error { + stack_encrypt::Error::Aead => STATUS_AUTH, + stack_encrypt::Error::Term(_) => STATUS_TERM, + stack_encrypt::Error::ForeignKeyset { .. } => STATUS_FOREIGN_KEYSET, + stack_encrypt::Error::Kms(kms) => status_for_kms(kms), + // A context that renders past ZeroKMS's descriptor limit is the + // caller's input, refused before any request is sent. + stack_encrypt::Error::DescriptorTooLong { .. } => STATUS_ENCODING, + _ => STATUS_INTERNAL, + } +} + +/// A dynamic-path error as a status code. +/// +/// The split the library draws is the one the ABI needs: `Context`, `Term`, +/// `Plan`, `Source` and `Record` are each a statement about the caller's +/// input, decided before any key is minted or retrieved, so they are +/// [`STATUS_ENCODING`] — named one by one, because that verdict is the +/// host's to act on and must be given deliberately. `Cipher` defers to +/// [`status_for_error`]; `Internal` is the library's own invariant failing +/// — a slot count that did not line up, a re-proof that could not fail — +/// and is [`STATUS_INTERNAL`], never a verdict on the input. +/// +/// The catch-all is required (`Error` is `#[non_exhaustive]`, so a variant +/// added upstream cannot fail this match at compile time) and it goes to +/// [`STATUS_INTERNAL`]: an unclassified failure is reported as ours until +/// someone reads the new variant and says otherwise here. The one wrong +/// default would be the other way round — telling a host to fix its input +/// over a fault that is not in its input. +pub fn status_for_dynamic(error: &stack_encrypt::dynamic::Error) -> u32 { + use stack_encrypt::dynamic::Error; + match error { + Error::Context | Error::Term { .. } | Error::Plan | Error::Source | Error::Record => { + STATUS_ENCODING + } + Error::Cipher(e) => status_for_error(e), + Error::Internal => STATUS_INTERNAL, + _ => STATUS_INTERNAL, + } +} + +// These matches are deliberately exhaustive — no `_` arms. None of the +// stack-kms error enums is `#[non_exhaustive]`, so exhaustiveness is free +// compiler coverage: `GenerateKeyError` already grew `Unauthorized` / +// `Forbidden` out of the shared `From<ViturRequestError>` pattern, and a +// variant added tomorrow must be classified here before this crate builds, +// instead of silently falling through a catch-all to [`STATUS_KMS_OTHER`] +// and costing a Go host its refresh signal. +fn status_for_kms(error: &stack_kms::Error) -> u32 { + match error { + stack_kms::Error::GenerateKey(e) => match e { + GenerateKeyError::Unauthorized => STATUS_KMS_UNAUTHORIZED, + GenerateKeyError::Forbidden => STATUS_KMS_FORBIDDEN, + GenerateKeyError::RequestFailed(e) => status_for_kind(&e.kind), + GenerateKeyError::GenerateIv(_) => STATUS_INTERNAL, + // A response that did not line up with the request, or key + // material the client could not use: server-side malformations. + GenerateKeyError::InvalidNumberOfKeys { .. } + | GenerateKeyError::InvalidKeyMaterial(_) => STATUS_KMS_OTHER, + }, + stack_kms::Error::RetrieveKey(e) => match e { + RetrieveKeyError::RequestFailed(e) => status_for_kind(&e.kind), + // A per-key server-side "no key for this iv/tag". + RetrieveKeyError::FailedRetrieval(_) => STATUS_KMS_NOT_FOUND, + RetrieveKeyError::InvalidNumberOfKeys { .. } + | RetrieveKeyError::InvalidKeyMaterial(_) => STATUS_KMS_OTHER, + }, + stack_kms::Error::LoadKeyset(e) => match e { + LoadKeysetError::Unauthorized(_) => STATUS_KMS_UNAUTHORIZED, + LoadKeysetError::Forbidden(_) => STATUS_KMS_FORBIDDEN, + LoadKeysetError::KeysetNotFound(_) => STATUS_KMS_NOT_FOUND, + LoadKeysetError::RequestFailed(e) => status_for_kind(&e.kind), + LoadKeysetError::InvalidKeyMaterial(_) => STATUS_KMS_OTHER, + }, + stack_kms::Error::Auth(auth) => status_for_auth(auth), + stack_kms::Error::ConnectionInit(_) | stack_kms::Error::InvalidEndpoint(_) => { + STATUS_KMS_TRANSPORT + } + stack_kms::Error::Unexpected(_) => STATUS_KMS_OTHER, + } +} + +/// Split the auth strategy's failures into "the credential was refused" +/// (retry after a refresh) and "the client is misconfigured" (retrying is a +/// spin). +/// +/// This split matters because `StackKms::get_token` runs *before* any request +/// leaves the guest and folds two very different things into +/// [`stack_kms::Error::Auth`]: a genuinely refused credential, and +/// `token.zerokms_url()` failing because the config named no `zerokms_url` +/// and the host's token carries no ZeroKMS `services` claim — an +/// `AuthError::InvalidToken`. Mapping the latter to +/// [`STATUS_KMS_UNAUTHORIZED`] would tell a Go host to refresh its token and +/// try again, forever, over a config problem no token can fix. +fn status_for_auth(error: &AuthError) -> u32 { + match error { + // The server (or the strategy) refused the credential itself: a new + // token is the fix. `AuthError` is `#[non_exhaustive]`, so the arms + // below need the `_` catch-all and a variant added upstream would + // silently classify as TRANSPORT ("do not refresh") — which is why + // the refresh signal keys off `is_credential_rejection()`, whose + // match *is* exhaustive inside stack-auth: new refused-credential + // variants are classified there, at compile time, and picked up here + // with no change. + e if e.is_credential_rejection() => STATUS_KMS_UNAUTHORIZED, + // Authenticated, but not allowed. + AuthError::AccessDenied(_) | AuthError::UsageLimitExceeded(_) => STATUS_KMS_FORBIDDEN, + // Server-side faults with no client-side remedy. + AuthError::Server(_) | AuthError::Internal(_) => STATUS_KMS_OTHER, + // Everything else is configuration or host transport: a malformed or + // claim-less token (`InvalidToken` — the unresolved-endpoint case), a + // bad URL/CRN/region/workspace, a failed request to the token issuer, + // or `Custom`, which is what `HostTokenStrategy` reports when the + // host's `token_get` import returns non-zero or hands back bytes that + // are not a token. + _ => STATUS_KMS_TRANSPORT, + } +} + +fn status_for_kind(kind: &ViturRequestErrorKind) -> u32 { + match kind { + ViturRequestErrorKind::Unauthorized => STATUS_KMS_UNAUTHORIZED, + ViturRequestErrorKind::Forbidden => STATUS_KMS_FORBIDDEN, + ViturRequestErrorKind::NotFound => STATUS_KMS_NOT_FOUND, + ViturRequestErrorKind::Conflict => STATUS_KMS_CONFLICT, + ViturRequestErrorKind::PrepareRequest | ViturRequestErrorKind::SendRequest => { + STATUS_KMS_TRANSPORT + } + _ => STATUS_KMS_OTHER, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use zerokms_protocol::ViturRequestError; + + fn vitur(kind: ViturRequestErrorKind) -> ViturRequestError { + ViturRequestError::new(kind, "stubbed", std::io::Error::other("boom")) + } + + #[test] + fn aead_and_composition_errors_map_to_the_vitaminc_codes() { + assert_eq!(status_for_error(&stack_encrypt::Error::Aead), STATUS_AUTH); + assert_eq!( + status_for_error(&stack_encrypt::Error::DescriptorTooLong { len: 513 }), + STATUS_ENCODING + ); + assert_eq!( + status_for_error(&stack_encrypt::Error::ResponseShape), + STATUS_INTERNAL + ); + } + + #[test] + fn kms_request_kinds_map_to_distinct_codes() { + let cases = [ + (ViturRequestErrorKind::Unauthorized, STATUS_KMS_UNAUTHORIZED), + (ViturRequestErrorKind::Forbidden, STATUS_KMS_FORBIDDEN), + (ViturRequestErrorKind::NotFound, STATUS_KMS_NOT_FOUND), + (ViturRequestErrorKind::Conflict, STATUS_KMS_CONFLICT), + (ViturRequestErrorKind::SendRequest, STATUS_KMS_TRANSPORT), + (ViturRequestErrorKind::ParseResponse, STATUS_KMS_OTHER), + ]; + for (i, (kind, expected)) in cases.into_iter().enumerate() { + let err = stack_encrypt::Error::Kms(stack_kms::Error::RetrieveKey( + RetrieveKeyError::RequestFailed(vitur(kind)), + )); + assert_eq!(status_for_error(&err), expected, "case {i}"); + } + } + + #[test] + fn a_missing_data_key_is_not_found() { + let err = stack_encrypt::Error::Kms(stack_kms::Error::RetrieveKey( + RetrieveKeyError::FailedRetrieval("no key".into()), + )); + assert_eq!(status_for_error(&err), STATUS_KMS_NOT_FOUND); + } + + /// The exact configuration the guest hits when `se_cipher_init` is given + /// no `zerokms_url` and the host hands over a token with no ZeroKMS + /// `services` claim: `StackKms::get_token` fails *before* sending + /// anything, with the real error this produces. It must not read as "your + /// token was rejected". + #[test] + fn an_unresolvable_endpoint_is_transport_not_unauthorized() { + use stack_auth::{SecretToken, ServiceToken}; + + // A token that is not a CTS-minted JWT, so it carries no services + // claim at all — the error comes from `zerokms_url()` itself, not a + // hand-built variant. + let token = ServiceToken::new(SecretToken::new("not-a-cts-jwt")); + let err = token + .zerokms_url() + .expect_err("a non-JWT has no services claim"); + assert!( + matches!(err, stack_auth::AuthError::InvalidToken(_)), + "expected InvalidToken, got: {err:?}" + ); + + let status = status_for_error(&stack_encrypt::Error::Kms(stack_kms::Error::Auth(err))); + assert_eq!( + status, STATUS_KMS_TRANSPORT, + "a config fault must not tell the host to refresh and retry" + ); + } + + #[test] + fn a_failed_host_token_import_is_transport_not_unauthorized() { + // What `HostTokenStrategy` reports when `token_get` returns non-zero. + let err = stack_auth::AuthError::Custom(stack_auth::CustomError( + "host token_get failed with status 7".to_string(), + )); + assert_eq!( + status_for_error(&stack_encrypt::Error::Kms(stack_kms::Error::Auth(err))), + STATUS_KMS_TRANSPORT + ); + } + + #[test] + fn a_refused_credential_is_still_unauthorized() { + let err = stack_auth::AuthError::TokenExpired(stack_auth::TokenExpired); + assert_eq!( + status_for_error(&stack_encrypt::Error::Kms(stack_kms::Error::Auth(err))), + STATUS_KMS_UNAUTHORIZED + ); + } + + #[test] + fn a_foreign_keyset_is_its_own_status_and_scope_bugs_are_internal() { + let (a, b) = (uuid::Uuid::from_u128(1), uuid::Uuid::from_u128(2)); + assert_eq!( + status_for_error(&stack_encrypt::Error::ForeignKeyset { + expected: a, + found: b + }), + STATUS_FOREIGN_KEYSET + ); + assert_eq!( + status_for_error(&stack_encrypt::Error::KeysetMismatch { left: a, right: b }), + STATUS_INTERNAL + ); + assert_eq!( + status_for_error(&stack_encrypt::Error::NoKeyset), + STATUS_INTERNAL + ); + } + + #[test] + fn dynamic_input_errors_are_encoding_and_a_library_bug_is_internal() { + use stack_encrypt::dynamic::{Error, TermKind}; + for (label, err) in [ + ("a bad context", Error::Context), + ( + "a bad term request", + Error::Term { + kind: TermKind::Match, + }, + ), + ("a bad plan", Error::Plan), + ("a bad source", Error::Source), + ("a bad record", Error::Record), + ] { + assert_eq!( + status_for_dynamic(&err), + STATUS_ENCODING, + "{label} is the caller's input" + ); + } + assert_eq!( + status_for_dynamic(&Error::Internal), + STATUS_INTERNAL, + "a library invariant failing is never the caller's fault" + ); + assert_eq!( + status_for_dynamic(&Error::Cipher(stack_encrypt::Error::Aead)), + STATUS_AUTH, + "a cipher failure keeps its own status" + ); + } + + #[test] + fn term_errors_are_derivation_failures() { + // An empty context never reaches a term — it is `STATUS_ENCODING` + // at the boundary, where the context is proven — so every term + // error that does arrive is a derivation failure. + assert_eq!( + status_for_error(&stack_encrypt::Error::Term( + stack_encrypt::sem::TermError::EmptyTermText + )), + STATUS_TERM + ); + } +} diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs new file mode 100644 index 000000000..541f548c6 --- /dev/null +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -0,0 +1,1515 @@ +//! Native tests of the guest's operations over `FakeDataKeySource` — the +//! same functions the wasm ABI drives, minus linear memory. What they pin: +//! +//! * value trees round-trip through the FFI codec + the guest ops +//! (including element mode and passthrough subtrees); +//! * a leaf inside a guest ciphertext tree **is** the frozen `SealedValue` +//! storage encoding — a native cipher decrypts it; +//! * terms derived through the guest dispatch are byte-identical to the +//! native `sem` calls (the cross-language contract); +//! * record encryption follows its plan, keeps to **one** ZeroKMS call per +//! invocation however many rows, and round-trips; +//! * hostile/malformed inputs and wrong-AAD decrypts map to the documented +//! statuses. + +use std::borrow::Cow; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use std::future::IntoFuture; + +use stack_encrypt::dynamic::Scope; +use stack_encrypt::sem::DefaultMatch; +use stack_encrypt::{nonempty, CipherText, Encrypt, SealedValue, StackCipher}; +use stack_encrypt_guest::ops::{self, TERM_EQUALITY, TERM_MATCH, TERM_OPE, TERM_ORE}; +use stack_encrypt_guest::status::{STATUS_AUTH, STATUS_ENCODING, STATUS_FOREIGN_KEYSET}; +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IndexKeySource, + RetrieveKeyPayload, +}; +use uuid::Uuid; +use vitaminc_aead_value::{transport as codec, FfiValue}; +use vitaminc_protected::{Controlled, Protected}; +use zerokms_protocol::{IdentifiedBy, UnverifiedContext}; + +/// `futures::executor::block_on` over anything awaitable: the term API +/// returns a `Pending`, which is `IntoFuture` rather than `Future`. +fn block_on<F: IntoFuture>(f: F) -> F::Output { + futures::executor::block_on(f.into_future()) +} + +// ============================================================================= +// Harness +// ============================================================================= + +/// `FakeDataKeySource` with call counters, so the tests can assert the +/// batching contract ("one `generate_keys` per invocation") instead of +/// trusting it. +#[derive(Default)] +struct Counting { + inner: FakeDataKeySource, + generate_calls: AtomicUsize, + retrieve_calls: AtomicUsize, +} + +impl DataKeySource for Counting { + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, stack_kms::Error> { + self.generate_calls.fetch_add(1, Ordering::SeqCst); + self.inner + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, stack_kms::Error> { + self.retrieve_calls.fetch_add(1, Ordering::SeqCst); + self.inner + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } +} + +impl IndexKeySource for Counting { + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, stack_kms::IndexKey), stack_kms::Error> { + self.inner.load_index_key(keyset_id).await + } +} + +fn cipher() -> StackCipher<Counting> { + block_on(StackCipher::builder().kms(Counting::default()).init()).expect("build cipher") +} + +fn encode(value: FfiValue) -> Vec<u8> { + let mut out = Vec::new(); + codec::encode_value(value, &mut out).expect("encode value"); + out +} + +fn decode(bytes: &[u8]) -> FfiValue { + codec::decode_value(&mut codec::Reader::new(bytes)).expect("decode value") +} + +/// Decode a guest ciphertext buffer with the passthrough payload kept as a +/// plain [`FfiValue`], so tests can inspect term nodes directly. +fn decode_tree(bytes: &[u8]) -> CipherText<Vec<u8>, FfiValue> { + codec::decode_ciphertext(&mut codec::Reader::new(bytes)).expect("decode ciphertext tree") +} + +fn text(value: &FfiValue) -> &str { + match value { + FfiValue::String(s) => std::str::from_utf8(s.risky_ref()).expect("utf8"), + other => panic!("expected a string, got {}", kind(other)), + } +} + +fn kind(value: &FfiValue) -> &'static str { + match value { + FfiValue::Null => "null", + FfiValue::Undefined => "undefined", + FfiValue::Bool(_) => "bool", + FfiValue::Int32(_) => "i32", + FfiValue::Int64(_) => "i64", + FfiValue::UInt32(_) => "u32", + FfiValue::UInt64(_) => "u64", + FfiValue::Float32(_) => "f32", + FfiValue::Float64(_) => "f64", + FfiValue::String(_) => "string", + FfiValue::Bytes(_) => "bytes", + FfiValue::Array(_) => "array", + FfiValue::Object(_) => "object", + FfiValue::Passthrough(_) => "passthrough", + } +} + +fn obj(entries: Vec<(&str, FfiValue)>) -> FfiValue { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) +} + +fn s(value: &str) -> FfiValue { + FfiValue::String(value.into()) +} + +/// The plan shape the record tests share — an ORE-indexed integer and a +/// match-indexed string, both stored — under whatever context `ctx` gives +/// each field. Output order is fixed here, and the tests index into it. +fn plan_under(ctx: impl Fn(&str) -> FfiValue) -> Vec<u8> { + encode(obj(vec![ + ( + "age", + obj(vec![ + ("context", ctx("age")), + ("outputs", FfiValue::Array(vec![s("c"), s("eq"), s("ore")])), + ]), + ), + ( + "name", + obj(vec![ + ("context", ctx("name")), + ("outputs", FfiValue::Array(vec![s("c"), s("match")])), + ]), + ), + ])) +} + +/// The plan used by the record tests: `plan_under` with flat contexts. +fn plan() -> Vec<u8> { + plan_under(|field| s(&format!("users/{field}"))) +} + +/// A one-field plan storing only the ciphertext, under `context`. +fn single_field_plan(field: &str, context: FfiValue) -> Vec<u8> { + encode(obj(vec![( + field, + obj(vec![ + ("context", context), + ("outputs", FfiValue::Array(vec![s("c")])), + ]), + )])) +} + +/// The bytes of a term node in a decoded record tree. +fn term_bytes(node: &CipherText<Vec<u8>, FfiValue>) -> Vec<u8> { + let CipherText::Passthrough(FfiValue::Bytes(b)) = node else { + panic!("expected a passthrough bytes term node"); + }; + b.risky_ref().to_vec() +} + +fn row(age: u32, name: &str) -> FfiValue { + obj(vec![("age", FfiValue::UInt32(age)), ("name", s(name))]) +} + +// ============================================================================= +// Values +// ============================================================================= + +#[test] +fn value_round_trips_through_the_guest_ops() { + let cipher = cipher(); + let value = obj(vec![ + ("email", s("alice@example.com")), + ("age", FfiValue::UInt32(34)), + ("id", FfiValue::Passthrough(Box::new(FfiValue::Int64(7)))), + ]); + + let ct = block_on(ops::encrypt_value( + &cipher.default_keyset(), + &encode(value), + b"users/42", + false, + )) + .expect("encrypt"); + let pt = block_on(ops::decrypt_value( + Scope::Client(&cipher), + &ct, + b"users/42", + false, + )) + .expect("decrypt"); + + let FfiValue::Object(entries) = decode(&pt) else { + panic!("expected an object back"); + }; + assert_eq!(entries.len(), 3); + assert_eq!(text(&entries[0].1), "alice@example.com"); + assert!(matches!(entries[1].1, FfiValue::UInt32(34))); + assert!( + matches!(&entries[2].1, FfiValue::Passthrough(inner) if matches!(**inner, FfiValue::Int64(7))) + ); + + assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 1); + assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn element_mode_round_trips() { + let cipher = cipher(); + let ct = block_on(ops::encrypt_value( + &cipher.default_keyset(), + &encode(s("row-0")), + b"users", + true, + )) + .expect("encrypt element"); + let pt = block_on(ops::decrypt_value( + Scope::Client(&cipher), + &ct, + b"users", + true, + )) + .expect("decrypt element"); + assert_eq!(text(&decode(&pt)), "row-0"); + + // An element is not a plain value: opening it without the element + // derivation must fail authentication. + assert_eq!( + block_on(ops::decrypt_value( + Scope::Client(&cipher), + &ct, + b"users", + false + )), + Err(STATUS_AUTH) + ); +} + +#[test] +fn guest_leaves_are_the_frozen_storage_encoding() { + // A leaf lifted out of the guest's codec framing is exactly the + // `SealedValue::from_bytes` storage format — a native cipher opens it. + let cipher = cipher(); + let ct = block_on(ops::encrypt_value( + &cipher.default_keyset(), + &encode(s("durable")), + b"ctx", + false, + )) + .expect("encrypt"); + + let CipherText::Single(leaf_bytes) = decode_tree(&ct) else { + panic!("expected a single leaf"); + }; + let leaf = SealedValue::from_bytes(&leaf_bytes).expect("frozen leaf encoding"); + // A guest leaf seals the *value model's* typed payload (`[tag] ++ + // payload`, the vitaminc sealed-leaf format), so the native open goes + // through `FfiValue`'s own `Decrypt` — not a bare `String`. The host's + // AAD is bytes, and a byte context is not a text context (vitaminc 0.5 + // types its leaves), so the native side opens under the byte slice. + let value: FfiValue = block_on(cipher.decrypt(CipherText::Single(leaf), b"ctx".as_slice())) + .expect("native decrypt of a guest leaf"); + assert_eq!(text(&value), "durable"); +} + +#[test] +fn wrong_aad_and_malformed_inputs_map_to_statuses() { + let cipher = cipher(); + let ct = block_on(ops::encrypt_value( + &cipher.default_keyset(), + &encode(s("x")), + b"ctx", + false, + )) + .expect("encrypt"); + + // Wrong AAD: authentication, not encoding. (The fake key source ignores + // descriptors; against ZeroKMS the retrieve is refused first, as + // `STATUS_KMS_FORBIDDEN` — see `status.rs`.) + assert_eq!( + block_on(ops::decrypt_value( + Scope::Client(&cipher), + &ct, + b"other", + false + )), + Err(STATUS_AUTH) + ); + // Garbage transport bytes on either path: encoding. + assert_eq!( + block_on(ops::encrypt_value( + &cipher.default_keyset(), + b"\xffgarbage", + b"ctx", + false + )), + Err(STATUS_ENCODING) + ); + assert_eq!( + block_on(ops::decrypt_value( + Scope::Client(&cipher), + b"\xffgarbage", + b"ctx", + false + )), + Err(STATUS_ENCODING) + ); + // A truncated leaf inside a well-formed tree: encoding (structural), + // never a parse of the wrong layout. + let CipherText::Single(leaf_bytes) = decode_tree(&ct) else { + panic!("expected a single leaf"); + }; + let mut out = Vec::new(); + codec::encode_ciphertext::<Vec<u8>, FfiValue>( + &CipherText::Single(leaf_bytes[..10].to_vec()), + &mut out, + ) + .expect("encode truncated"); + assert_eq!( + block_on(ops::decrypt_value( + Scope::Client(&cipher), + &out, + b"ctx", + false + )), + Err(STATUS_ENCODING) + ); +} + +/// The value paths are the cipher-directed path, and take the AAD as +/// `StackCipher::encrypt` does — any bytes, none included. An empty AAD +/// seals under no context and opens under the same, and a null pointer with +/// zero length is the same empty AAD (the ABI's `input` maps it so). Binding +/// a value to a field is the record and term paths' job, where the context is +/// a `NonEmpty`. +#[test] +fn an_empty_aad_round_trips_on_the_value_paths() { + let cipher = cipher(); + let value = encode(s("x")); + + for as_element in [false, true] { + let ct = block_on(ops::encrypt_value( + &cipher.default_keyset(), + &value, + b"", + as_element, + )) + .expect("encrypt under an empty aad"); + let out = block_on(ops::decrypt_value( + Scope::Client(&cipher), + &ct, + b"", + as_element, + )) + .expect("decrypt under an empty aad"); + assert_eq!(out, value, "element: {as_element}"); + + // Empty is a context like any other: not interchangeable with one + // that carries bytes. + assert_eq!( + block_on(ops::decrypt_value( + Scope::Client(&cipher), + &ct, + b"ctx", + as_element + )), + Err(STATUS_AUTH), + "element: {as_element}" + ); + } + + // Odd-looking but non-empty bytes are a context too, and bind. + let zeros = &[0u8; 8][..]; + let ct = block_on(ops::encrypt_value( + &cipher.default_keyset(), + &value, + zeros, + false, + )) + .expect("encrypt"); + let opened = decode( + &block_on(ops::decrypt_value( + Scope::Client(&cipher), + &ct, + zeros, + false, + )) + .expect("decrypt"), + ); + assert_eq!(text(&opened), "x"); + assert_eq!( + block_on(ops::decrypt_value( + Scope::Client(&cipher), + &ct, + b"ctx", + false + )), + Err(STATUS_AUTH) + ); +} + +// ============================================================================= +// Terms +// ============================================================================= + +#[test] +fn guest_terms_match_the_native_sem_derivations() { + let cipher = cipher(); + let ctx = encode(s("users/age")); + + let eq = block_on(ops::term( + &cipher.default_keyset(), + &encode(FfiValue::UInt32(42)), + &ctx, + TERM_EQUALITY, + )) + .expect("eq term"); + let native = block_on( + cipher + .default_keyset() + .equality_term(42u32, nonempty!("users/age")), + ) + .expect("native eq"); + assert_eq!(eq, native.as_bytes()); + + let ore = block_on(ops::term( + &cipher.default_keyset(), + &encode(FfiValue::UInt32(42)), + &ctx, + TERM_ORE, + )) + .expect("ore term"); + let native = block_on( + cipher + .default_keyset() + .ore_term(42u32, nonempty!("users/age")), + ) + .expect("native ore"); + assert_eq!(ore, native.as_ref()); + + let ope = block_on(ops::term( + &cipher.default_keyset(), + &encode(FfiValue::UInt32(42)), + &ctx, + TERM_OPE, + )) + .expect("ope term"); + let native = block_on( + cipher + .default_keyset() + .ope_term(42u32, nonempty!("users/age")), + ) + .expect("native ope"); + assert_eq!(ope, native.as_ref()); + + let m = block_on(ops::term( + &cipher.default_keyset(), + &encode(s("alice smith")), + &encode(s("users/name")), + TERM_MATCH, + )) + .expect("match term"); + let native = block_on( + cipher + .default_keyset() + .match_terms::<DefaultMatch>("alice smith", nonempty!("users/name")), + ) + .expect("native"); + assert_eq!(m, native.to_bytes()); + + // Strings and bytes have distinct PRF encodings — the guest must keep + // them apart even when their raw bytes are equal. + let eq_text = block_on(ops::term( + &cipher.default_keyset(), + &encode(s("ab")), + &encode(s("f")), + TERM_EQUALITY, + )) + .expect("text term"); + let eq_bytes = block_on(ops::term( + &cipher.default_keyset(), + &encode(FfiValue::Bytes(Protected::new(b"ab".to_vec()))), + &encode(s("f")), + TERM_EQUALITY, + )) + .expect("bytes term"); + assert_ne!(eq_text, eq_bytes); + let native_text = block_on( + cipher + .default_keyset() + .equality_term("ab".to_string(), nonempty!("f")), + ) + .expect("native"); + assert_eq!(eq_text, native_text.as_bytes()); + let native_bytes = block_on( + cipher + .default_keyset() + .equality_term(Protected::new(b"ab".to_vec()), nonempty!("f")), + ) + .expect("native"); + assert_eq!(eq_bytes, native_bytes.as_bytes()); + + // Variable-width CLLW output for strings. + let ore_s = block_on(ops::term( + &cipher.default_keyset(), + &encode(s("alice")), + &encode(s("users/name")), + TERM_ORE, + )) + .expect("string ore"); + assert_eq!(ore_s.len(), 5 * 8); + + // No ZeroKMS traffic for any of it. + assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 0); + assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn unsupported_term_inputs_are_encoding_errors() { + let cipher = cipher(); + let ctx = encode(s("f")); + + // Floats and bools have no equality encoding; match is text-only; + // containers have no term semantics; kinds outside the table and empty + // contexts are rejected. + for (value, kind) in [ + (FfiValue::Float64(1.5), TERM_EQUALITY), + (FfiValue::Bool(true), TERM_EQUALITY), + (FfiValue::UInt32(1), TERM_MATCH), + (FfiValue::Array(vec![]), TERM_ORE), + (FfiValue::Null, TERM_EQUALITY), + (FfiValue::UInt32(1), 99), + ] { + assert_eq!( + block_on(ops::term( + &cipher.default_keyset(), + &encode(value), + &ctx, + kind + )), + Err(STATUS_ENCODING), + "kind {kind}" + ); + } + assert_eq!( + block_on(ops::term( + &cipher.default_keyset(), + &encode(FfiValue::UInt32(1)), + &encode(s("")), + TERM_EQUALITY + )), + Err(STATUS_ENCODING), + "empty context" + ); + + // The context is codec-encoded, not raw text: the pre-structured form + // must be refused at the boundary, not read as a flat context; and a + // codec value that is not a context must be refused too. + for (label, context) in [ + ("raw utf-8 bytes", b"f".to_vec()), + ("a boolean", encode(FfiValue::Bool(true))), + ("an object", encode(obj(vec![("k", s("v"))]))), + ] { + assert_eq!( + block_on(ops::term( + &cipher.default_keyset(), + &encode(FfiValue::UInt32(1)), + &context, + TERM_EQUALITY + )), + Err(STATUS_ENCODING), + "a term context of {label} must be refused" + ); + } +} + +// ============================================================================= +// Records +// ============================================================================= + +#[test] +fn a_record_batch_encrypts_in_one_call_and_round_trips() { + let cipher = cipher(); + let source = encode(FfiValue::Array(vec![ + row(29, "alice smith"), + row(34, "bob jones"), + row(41, "carol park"), + ])); + + let record = block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &plan(), + )) + .expect("encrypt records"); + // Three rows, two ciphertext fields each: still exactly one call. + assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 1); + + let pt = block_on(ops::decrypt_record( + Scope::Client(&cipher), + &record, + &plan(), + )) + .expect("decrypt records"); + assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), 1); + + let FfiValue::Array(rows) = decode(&pt) else { + panic!("expected an array of rows back"); + }; + assert_eq!(rows.len(), 3); + let FfiValue::Object(fields) = &rows[1] else { + panic!("expected an object row"); + }; + assert_eq!(fields[0].0, "age"); + assert!(matches!(fields[0].1, FfiValue::UInt32(34))); + assert_eq!(fields[1].0, "name"); + assert_eq!(text(&fields[1].1), "bob jones"); +} + +/// The forgery that motivates `reject_passthrough_tree`: an attacker with +/// write access to the stored tree swaps a field's `"c"` subtree for a +/// passthrough carrying chosen plaintext. `decrypt_into` opens no AEAD for a +/// passthrough, so without the rejection this would come back as a +/// *successful* decrypt of attacker-chosen bytes. +#[test] +fn a_forged_passthrough_ciphertext_slot_is_rejected_not_decrypted() { + let cipher = cipher(); + let source = encode(row(29, "alice smith")); + let record = block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &plan(), + )) + .expect("encrypt record"); + + let CipherText::Map(mut fields) = decode_tree(&record) else { + panic!("expected a field map"); + }; + for (field, node) in &mut fields { + if field != "age" { + continue; + } + let CipherText::Map(outputs) = node else { + panic!("expected an output map"); + }; + for (key, slot) in outputs.iter_mut() { + if key == "c" { + *slot = CipherText::Passthrough(FfiValue::UInt32(99)); + } + } + } + let mut forged = Vec::new(); + codec::encode_ciphertext(&CipherText::Map(fields), &mut forged).expect("re-encode"); + + assert_eq!( + block_on(ops::decrypt_record( + Scope::Client(&cipher), + &forged, + &plan() + )), + Err(STATUS_ENCODING), + "a passthrough in a ciphertext slot must be a hard error, never plaintext" + ); +} + +/// The encrypt-side half of the same invariant: a source value containing a +/// passthrough must not reach a `"c"` slot (it would seal nothing for those +/// bytes), even nested inside a container. +#[test] +fn a_passthrough_source_value_is_refused_a_ciphertext_slot() { + let cipher = cipher(); + for age in [ + FfiValue::Passthrough(Box::new(FfiValue::UInt32(29))), + FfiValue::Array(vec![FfiValue::Passthrough(Box::new(FfiValue::UInt32(29)))]), + ] { + // A ciphertext-only plan, so the term path's own scalar rejection + // cannot mask the one under test. + let plan = encode(obj(vec![( + "age", + obj(vec![ + ("context", s("users/age")), + ("outputs", FfiValue::Array(vec![s("c")])), + ]), + )])); + let source = encode(obj(vec![("age", age)])); + assert_eq!( + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &plan + )), + Err(STATUS_ENCODING) + ); + } +} + +#[test] +fn record_terms_equal_the_native_derivations_and_probe_them() { + let cipher = cipher(); + let record = block_on(ops::encrypt_record( + &cipher.default_keyset(), + &encode(row(34, "alice smith")), + &plan(), + )) + .expect("encrypt record"); + + let CipherText::Map(fields) = decode_tree(&record) else { + panic!("expected a field map"); + }; + assert_eq!(fields.len(), 2); + let (age_name, CipherText::Map(age_outputs)) = &fields[0] else { + panic!("expected an output map for the first field"); + }; + assert_eq!(age_name, "age"); + assert_eq!( + age_outputs + .iter() + .map(|(k, _)| k.as_str()) + .collect::<Vec<_>>(), + vec!["c", "eq", "ore"], + "output order is the plan's" + ); + + // The stored terms are byte-identical to query-time probes built the + // native way — the property that makes the index searchable. + let eq_probe = block_on( + cipher + .default_keyset() + .equality_term(34u32, nonempty!("users/age")), + ) + .expect("probe"); + assert_eq!(term_bytes(&age_outputs[1].1), eq_probe.as_bytes()); + let ore_probe = block_on( + cipher + .default_keyset() + .ore_term(34u32, nonempty!("users/age")), + ) + .expect("probe"); + assert_eq!(term_bytes(&age_outputs[2].1), ore_probe.as_ref()); + + let (_, CipherText::Map(name_outputs)) = &fields[1] else { + panic!("expected an output map for the second field"); + }; + let match_probe = block_on( + cipher + .default_keyset() + .match_terms::<DefaultMatch>("alice smith", nonempty!("users/name")), + ) + .expect("probe"); + assert_eq!(term_bytes(&name_outputs[1].1), match_probe.to_bytes()); + + // And the "c" node is an ordinary value-model ciphertext bound to the + // field's context. + let CipherText::Single(leaf) = &age_outputs[0].1 else { + panic!("expected a single leaf for a scalar field"); + }; + let leaf = SealedValue::from_bytes(leaf).expect("frozen leaf"); + let value: FfiValue = block_on(cipher.decrypt(CipherText::Single(leaf), "users/age")) + .expect("native decrypt of a record field"); + assert!(matches!(value, FfiValue::UInt32(34))); +} + +// ============================================================================= +// Structured contexts +// ============================================================================= + +/// The caller extension a Rust row gets from +/// `encrypt_into_with_context(row, 7u64)`: every field's context becomes +/// `("users/<field>", 7u64)`. A plan spells it as a list. +fn extended(field: &str) -> FfiValue { + FfiValue::Array(vec![s(&format!("users/{field}")), FfiValue::UInt64(7)]) +} + +/// `plan()` under the extension. +fn extended_plan() -> Vec<u8> { + plan_under(extended) +} + +/// A plan whose context is a list seals exactly what the Rust derive seals +/// under a caller-extended context: the stored terms are the native probes +/// under `nonempty!("users/age").with(7u64)`, the guest's own probe under +/// the list is the same bytes, and the `"c"` leaf opens natively under the +/// tuple. The flat context is a different domain, as it must be. +#[test] +fn a_structured_plan_context_seals_what_the_native_extended_context_does() { + let cipher = cipher(); + let record = block_on(ops::encrypt_record( + &cipher.default_keyset(), + &encode(row(34, "alice smith")), + &extended_plan(), + )) + .expect("encrypt record"); + + let CipherText::Map(fields) = decode_tree(&record) else { + panic!("expected a field map"); + }; + let (_, CipherText::Map(age_outputs)) = &fields[0] else { + panic!("expected an output map for the first field"); + }; + + let native = nonempty!("users/age").with(7u64); + let eq_probe = block_on(cipher.default_keyset().equality_term(34u32, native)).expect("probe"); + assert_eq!(term_bytes(&age_outputs[1].1), eq_probe.as_bytes()); + let ore_probe = block_on(cipher.default_keyset().ore_term(34u32, native)).expect("probe"); + assert_eq!(term_bytes(&age_outputs[2].1), ore_probe.as_ref()); + let flat_probe = block_on( + cipher + .default_keyset() + .equality_term(34u32, nonempty!("users/age")), + ) + .expect("probe"); + assert_ne!( + term_bytes(&age_outputs[1].1), + flat_probe.as_bytes(), + "the extension domain-separates from the flat context" + ); + + let guest_probe = block_on(ops::term( + &cipher.default_keyset(), + &encode(FfiValue::UInt32(34)), + &encode(extended("age")), + TERM_EQUALITY, + )) + .expect("guest probe"); + assert_eq!(term_bytes(&age_outputs[1].1), guest_probe); + + let (_, CipherText::Map(name_outputs)) = &fields[1] else { + panic!("expected an output map for the second field"); + }; + let match_probe = block_on( + cipher + .default_keyset() + .match_terms::<DefaultMatch>("alice smith", nonempty!("users/name").with(7u64)), + ) + .expect("probe"); + assert_eq!(term_bytes(&name_outputs[1].1), match_probe.to_bytes()); + + let CipherText::Single(leaf) = &age_outputs[0].1 else { + panic!("expected a single leaf for a scalar field"); + }; + let leaf = SealedValue::from_bytes(leaf).expect("frozen leaf"); + let value: FfiValue = block_on(cipher.decrypt(CipherText::Single(leaf), native)) + .expect("native decrypt under the tuple"); + assert!(matches!(value, FfiValue::UInt32(34))); +} + +/// The reverse direction: a field sealed natively under the tuple — as a +/// Rust row sealed with a caller context is — opens through a plan whose +/// context is the same list, and not through the flat plan. +#[test] +fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() { + let cipher = cipher(); + let keyset = cipher.default_keyset(); + let native = nonempty!("users/age").with(7u64); + let sealed = block_on( + FfiValue::UInt32(34) + .encrypt_with_aad(&keyset, native) + .expect("encrypt") + .seal(&keyset, native), + ) + .expect("seal"); + let CipherText::Single(leaf) = sealed else { + panic!("a scalar seals to a single leaf"); + }; + + // Shape the tree the way `encrypt_record` writes it: field → { c: leaf }. + let tree: CipherText<Vec<u8>, FfiValue> = CipherText::Map(vec![( + "age".to_string(), + CipherText::Map(vec![("c".to_string(), CipherText::Single(leaf.to_bytes()))]), + )]); + let mut record = Vec::new(); + codec::encode_ciphertext(&tree, &mut record).expect("encode tree"); + + let plan_with = |context: FfiValue| single_field_plan("age", context); + + let opened = block_on(ops::decrypt_record( + Scope::Client(&cipher), + &record, + &plan_with(extended("age")), + )) + .expect("open through the plan"); + let FfiValue::Object(fields) = decode(&opened) else { + panic!("a record decrypts to an object"); + }; + assert!(matches!(fields.as_slice(), [(name, FfiValue::UInt32(34))] if name == "age")); + + assert_eq!( + block_on(ops::decrypt_record( + Scope::Client(&cipher), + &record, + &plan_with(s("users/age")) + )), + Err(STATUS_AUTH), + "the flat context is not the one it was sealed under" + ); +} + +/// A plan context that is not a context — the wrong value kind, or empty +/// by the tuple rule — is refused at parse, before anything is sealed. +#[test] +fn a_structured_plan_context_is_validated_at_parse() { + let cipher = cipher(); + let plan_with = |context: FfiValue| single_field_plan("f", context); + let source = encode(obj(vec![("f", FfiValue::UInt32(1))])); + + for (label, bad) in [ + ("a boolean", FfiValue::Bool(true)), + ("a float", FfiValue::Float64(7.0)), + ("an object", FfiValue::Object(vec![])), + ("an empty list", FfiValue::Array(vec![])), + ("a list of one empty string", FfiValue::Array(vec![s("")])), + ( + "a list with a float in it", + FfiValue::Array(vec![s("users/age"), FfiValue::Float64(7.0)]), + ), + ] { + assert_eq!( + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &plan_with(bad) + )), + Err(STATUS_ENCODING), + "a plan context of {label} must be refused at parse" + ); + } + assert_eq!( + cipher.kms().generate_calls.load(Ordering::SeqCst), + 0, + "nothing seals under a context that is not one" + ); + + // Non-empty by the tuple rule: one part carries bytes. + let sealed = block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &plan_with(FfiValue::Array(vec![s(""), FfiValue::UInt64(7)])), + )) + .expect("an integer part is never empty"); + assert!(block_on(ops::decrypt_record( + Scope::Client(&cipher), + &sealed, + &plan_with(FfiValue::Array(vec![s(""), FfiValue::UInt64(7)])) + )) + .is_ok()); +} + +#[test] +fn record_shape_violations_are_encoding_errors() { + let cipher = cipher(); + + // A field missing from the row, an extra field, a non-scalar term + // source, and malformed plans. + let missing = encode(obj(vec![("age", FfiValue::UInt32(1))])); + assert_eq!( + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &missing, + &plan() + )), + Err(STATUS_ENCODING) + ); + + let extra = encode(obj(vec![ + ("age", FfiValue::UInt32(1)), + ("name", s("a")), + ("stray", s("b")), + ])); + assert_eq!( + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &extra, + &plan() + )), + Err(STATUS_ENCODING) + ); + + let nested = encode(obj(vec![ + ("age", FfiValue::Array(vec![FfiValue::UInt32(1)])), + ("name", s("a")), + ])); + assert_eq!( + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &nested, + &plan() + )), + Err(STATUS_ENCODING), + "a term-indexed field must be a scalar" + ); + + for bad_plan in [ + obj(vec![]), // empty + obj(vec![("f", obj(vec![("context", s("c"))]))]), // no outputs + obj(vec![( + "f", + obj(vec![ + ("context", s("")), + ("outputs", FfiValue::Array(vec![s("c")])), + ]), + )]), // empty context + obj(vec![( + "f", + obj(vec![ + ("context", s("c")), + ("outputs", FfiValue::Array(vec![s("nope")])), + ]), + )]), // unknown output + obj(vec![( + "f", + obj(vec![ + ("context", s("c")), + ("outputs", FfiValue::Array(vec![s("eq"), s("eq")])), + ]), + )]), // duplicate output + ] { + assert_eq!( + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &encode(obj(vec![("f", FfiValue::UInt32(1))])), + &encode(bad_plan), + )), + Err(STATUS_ENCODING) + ); + } + + // No data keys were minted for any rejected call. + assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 0); +} + +/// An empty plan context is refused at plan-parse time, before anything is +/// sealed — and on both record paths, so neither half can drift into +/// accepting what the other refuses. (`encrypt_record` seals through the +/// cipher-directed path, which accepts any AAD; `decrypt_record` opens +/// through `decrypt_into`, which takes a `NonEmpty<_>`: proving the context +/// once, at parse, is what keeps a row from encrypting and then never +/// decrypting.) +#[test] +fn an_empty_plan_context_is_refused_before_anything_is_sealed() { + let cipher = cipher(); + let bad_plan = encode(obj(vec![( + "f", + obj(vec![ + ("context", s("")), + ("outputs", FfiValue::Array(vec![s("c")])), + ]), + )])); + let source = encode(obj(vec![("f", FfiValue::UInt32(1))])); + + assert_eq!( + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &bad_plan + )), + Err(STATUS_ENCODING) + ); + assert_eq!( + cipher.kms().generate_calls.load(Ordering::SeqCst), + 0, + "a context that could never be decrypted under must not seal" + ); + assert_eq!( + block_on(ops::decrypt_record( + Scope::Client(&cipher), + &source, + &bad_plan + )), + Err(STATUS_ENCODING) + ); + + // A context of unusual bytes is still a context: it seals, and opens. + let odd = String::from_utf8(vec![0u8; 8]).expect("nul bytes are valid utf-8"); + let odd_plan = encode(obj(vec![( + "f", + obj(vec![ + ("context", s(&odd)), + ("outputs", FfiValue::Array(vec![s("c")])), + ]), + )])); + let sealed = block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &odd_plan, + )) + .expect("encrypt"); + let opened = block_on(ops::decrypt_record( + Scope::Client(&cipher), + &sealed, + &odd_plan, + )) + .expect("decrypt"); + let FfiValue::Object(fields) = decode(&opened) else { + panic!("a record decrypts to an object"); + }; + assert!(matches!(fields.as_slice(), [(name, FfiValue::UInt32(1))] if name == "f")); +} + +// ============================================================================= +// Keysets: the scope a call selects +// ============================================================================= + +fn keyset_named<'c>( + cipher: &'c StackCipher<Counting>, + name: &str, +) -> stack_encrypt::KeysetCipher<'c, Counting> { + block_on(cipher.keyset(IdentifiedBy::Name(name.to_string().into()))).expect("select keyset") +} + +/// A value sealed under a tenant's keyset opens through that keyset, through +/// `{"any"}`, and not through another tenant's — and the refusal costs no +/// ZeroKMS call. +#[test] +fn a_value_opens_under_its_own_keyset_or_any_but_not_another() { + let cipher = cipher(); + let acme = keyset_named(&cipher, "acme"); + let globex = keyset_named(&cipher, "globex"); + let ct = block_on(ops::encrypt_value(&acme, &encode(s("x")), b"ctx", false)).expect("encrypt"); + + let pt = block_on(ops::decrypt_value( + Scope::Keyset(acme.clone()), + &ct, + b"ctx", + false, + )) + .expect("own keyset opens"); + assert_eq!(text(&decode(&pt)), "x"); + let pt = block_on(ops::decrypt_value( + Scope::Client(&cipher), + &ct, + b"ctx", + false, + )) + .expect("any opens"); + assert_eq!(text(&decode(&pt)), "x"); + let retrieves = cipher.kms().retrieve_calls.load(Ordering::SeqCst); + + assert_eq!( + block_on(ops::decrypt_value( + Scope::Keyset(globex), + &ct, + b"ctx", + false + )), + Err(STATUS_FOREIGN_KEYSET), + "another tenant's keyset must refuse the leaf" + ); + assert_eq!( + cipher.kms().retrieve_calls.load(Ordering::SeqCst), + retrieves, + "the refusal happens before any key is retrieved" + ); +} + +/// A record batch whose rows were sealed under different keysets opens +/// through `{"any"}` in one call per keyset, and not through one keyset. +#[test] +fn a_mixed_keyset_record_batch_opens_through_any_one_call_per_keyset() { + let cipher = cipher(); + let acme = keyset_named(&cipher, "acme"); + let globex = keyset_named(&cipher, "globex"); + + // One row per tenant, sealed separately; a host stores them side by + // side and reads them back as one batch. + let acme_rows = block_on(ops::encrypt_record( + &acme, + &encode(row(29, "alice smith")), + &plan(), + )) + .expect("encrypt acme row"); + let globex_rows = block_on(ops::encrypt_record( + &globex, + &encode(row(34, "bob jones")), + &plan(), + )) + .expect("encrypt globex row"); + let batch = { + let (CipherText::Map(a), CipherText::Map(g)) = ( + codec::decode_ciphertext_boxed::<Vec<u8>>(&mut codec::Reader::new(&acme_rows)) + .expect("decode"), + codec::decode_ciphertext_boxed::<Vec<u8>>(&mut codec::Reader::new(&globex_rows)) + .expect("decode"), + ) else { + panic!("a single record is a map"); + }; + let mut out = Vec::new(); + codec::encode_ciphertext_boxed( + CipherText::Sequence(vec![CipherText::Map(a), CipherText::Map(g)]), + &mut out, + ) + .expect("encode batch"); + out + }; + + let before = cipher.kms().retrieve_calls.load(Ordering::SeqCst); + let pt = block_on(ops::decrypt_record(Scope::Client(&cipher), &batch, &plan())) + .expect("any opens the mixed batch"); + assert_eq!( + cipher.kms().retrieve_calls.load(Ordering::SeqCst) - before, + 2, + "one retrieve per keyset" + ); + let FfiValue::Array(rows) = decode(&pt) else { + panic!("expected an array of rows back"); + }; + assert_eq!(rows.len(), 2); + + let before = cipher.kms().retrieve_calls.load(Ordering::SeqCst); + assert_eq!( + block_on(ops::decrypt_record(Scope::Keyset(acme), &batch, &plan())), + Err(STATUS_FOREIGN_KEYSET) + ); + assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), before); +} + +/// Terms derive under the selected keyset's index key: the same probe +/// under two keysets differs, and matches the native derivation for each. +#[test] +fn terms_derive_under_the_selected_keyset() { + let cipher = cipher(); + let acme = keyset_named(&cipher, "acme"); + let globex = keyset_named(&cipher, "globex"); + let ctx = encode(s("users/age")); + + let acme_term = block_on(ops::term( + &acme, + &encode(FfiValue::UInt32(42)), + &ctx, + TERM_EQUALITY, + )) + .expect("acme term"); + let globex_term = block_on(ops::term( + &globex, + &encode(FfiValue::UInt32(42)), + &ctx, + TERM_EQUALITY, + )) + .expect("globex term"); + assert_ne!(acme_term, globex_term); + + let native = block_on(acme.equality_term(42u32, nonempty!("users/age"))).expect("native"); + assert_eq!(acme_term, native.into_bytes().to_vec()); +} + +// ============================================================================= +// Validation precedence +// ============================================================================= + +/// The ABI runs `ops::validate` on every input before it consults the +/// cipher, so a malformed call must be refused there — not by the operation +/// after a keyset has been resolved. These pin that the validators reject +/// exactly the inputs the operations reject as `STATUS_ENCODING`, on a +/// static path that needs no cipher at all, and accept what the operations +/// accept. +#[test] +fn term_validation_refuses_what_the_term_op_refuses() { + let cipher = cipher(); + let ctx = encode(s("f")); + + for (label, value, kind) in [ + ( + "a float under equality", + FfiValue::Float64(1.5), + TERM_EQUALITY, + ), + ("a bool under equality", FfiValue::Bool(true), TERM_EQUALITY), + ("an integer under match", FfiValue::UInt32(1), TERM_MATCH), + ("a container", FfiValue::Array(vec![]), TERM_ORE), + ("an object", obj(vec![("k", s("v"))]), TERM_EQUALITY), + ("null", FfiValue::Null, TERM_OPE), + ("an unknown kind", FfiValue::UInt32(1), 99), + ] { + let value = encode(value); + assert_eq!( + ops::validate::term(&value, &ctx, kind), + Err(STATUS_ENCODING), + "{label} must be refused by validation" + ); + assert_eq!( + block_on(ops::term(&cipher.default_keyset(), &value, &ctx, kind)), + Err(STATUS_ENCODING), + "{label} must be refused by the op too" + ); + } + for (label, context) in [ + ("an empty context", encode(s(""))), + ( + "a context that is not a context", + encode(FfiValue::Bool(true)), + ), + ("raw bytes for a context", b"f".to_vec()), + ] { + assert_eq!( + ops::validate::term(&encode(FfiValue::UInt32(1)), &context, TERM_EQUALITY), + Err(STATUS_ENCODING), + "{label} must be refused by validation" + ); + } + + // Every pair the scheme defines passes. + for (value, kind) in [ + (FfiValue::UInt32(1), TERM_EQUALITY), + (FfiValue::Int64(-1), TERM_EQUALITY), + (s("x"), TERM_EQUALITY), + (FfiValue::Bytes(Protected::new(vec![1])), TERM_EQUALITY), + (s("x y"), TERM_MATCH), + (FfiValue::Float64(1.5), TERM_ORE), + (FfiValue::Bool(true), TERM_OPE), + (s("x"), TERM_ORE), + ] { + assert_eq!(ops::validate::term(&encode(value), &ctx, kind), Ok(())); + } +} + +#[test] +fn record_validation_refuses_what_encrypt_record_refuses() { + let cipher = cipher(); + let plan = plan(); + + for (label, source) in [ + ("a missing field", obj(vec![("age", FfiValue::UInt32(1))])), + ( + "an extra field", + obj(vec![ + ("age", FfiValue::UInt32(1)), + ("name", s("a")), + ("stray", s("b")), + ]), + ), + ( + "a container under a term output", + obj(vec![ + ("age", FfiValue::Array(vec![FfiValue::UInt32(1)])), + ("name", s("a")), + ]), + ), + ( + "a float under equality", + obj(vec![("age", FfiValue::Float64(1.0)), ("name", s("a"))]), + ), + ( + "an integer under match", + obj(vec![ + ("age", FfiValue::UInt32(1)), + ("name", FfiValue::UInt32(2)), + ]), + ), + ("a scalar, not a record", FfiValue::UInt32(1)), + ( + "a batch holding a non-record", + FfiValue::Array(vec![row(1, "a"), FfiValue::Null]), + ), + ] { + let source = encode(source); + assert_eq!( + ops::validate::record(&source, &plan), + Err(STATUS_ENCODING), + "{label} must be refused by validation" + ); + assert_eq!( + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &plan + )), + Err(STATUS_ENCODING), + "{label} must be refused by the op too" + ); + } + + // A passthrough under a ciphertext output, with no term output to mask + // it (the invariant `a_passthrough_source_value_is_refused_a_ciphertext_slot` pins). + let ct_only = single_field_plan("age", s("users/age")); + let passthrough = encode(obj(vec![( + "age", + FfiValue::Passthrough(Box::new(FfiValue::UInt32(29))), + )])); + assert_eq!( + ops::validate::record(&passthrough, &ct_only), + Err(STATUS_ENCODING) + ); + + // A malformed plan is refused with a well-formed source. + assert_eq!( + ops::validate::record(&encode(row(1, "a")), &encode(obj(vec![]))), + Err(STATUS_ENCODING) + ); + + assert_eq!(ops::validate::record(&encode(row(1, "a")), &plan), Ok(())); + assert_eq!( + ops::validate::record( + &encode(FfiValue::Array(vec![row(1, "a"), row(2, "b")])), + &plan + ), + Ok(()) + ); + assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn record_tree_validation_refuses_what_decrypt_record_refuses() { + let cipher = cipher(); + let plan = plan(); + let record = block_on(ops::encrypt_record( + &cipher.default_keyset(), + &encode(row(29, "alice")), + &plan, + )) + .expect("encrypt record"); + assert_eq!(ops::validate::record_tree(&record, &plan), Ok(())); + + type Node = CipherText<Vec<u8>, FfiValue>; + // The tree is not `Clone`; every variant decodes the record afresh. + let fields = || { + let CipherText::Map(fields) = decode_tree(&record) else { + panic!("expected a field map"); + }; + fields + }; + let re_encode = |tree: Node| { + let mut out = Vec::new(); + codec::encode_ciphertext(&tree, &mut out).expect("re-encode"); + out + }; + let with_age = |edit: &dyn Fn(&mut Node)| { + let mut fields = fields(); + for (field, node) in &mut fields { + if field == "age" { + edit(node); + } + } + CipherText::Map(fields) + }; + + let forged_c = with_age(&|node| { + let CipherText::Map(outputs) = node else { + panic!("expected an output map"); + }; + for (key, slot) in outputs.iter_mut() { + if key == "c" { + *slot = CipherText::Passthrough(FfiValue::UInt32(99)); + } + } + }); + let no_c = with_age(&|node| { + let CipherText::Map(outputs) = node else { + panic!("expected an output map"); + }; + outputs.retain(|(key, _)| key != "c"); + }); + let not_a_map = with_age(&|node| *node = CipherText::Passthrough(FfiValue::Null)); + let missing_field = { + let mut fields = fields(); + fields.retain(|(field, _)| field != "age"); + CipherText::Map(fields) + }; + let batch_of_non_records = CipherText::Sequence(vec![ + CipherText::Map(fields()), + CipherText::Passthrough(FfiValue::Null), + ]); + + for (label, tree) in [ + ("a forged passthrough under c", forged_c), + ("a field without c", no_c), + ("a field that is not an output map", not_a_map), + ("a missing field", missing_field), + ("a batch holding a non-record", batch_of_non_records), + ] { + let tree = re_encode(tree); + assert_eq!( + ops::validate::record_tree(&tree, &plan), + Err(STATUS_ENCODING), + "{label} must be refused by validation" + ); + assert_eq!( + block_on(ops::decrypt_record(Scope::Client(&cipher), &tree, &plan)), + Err(STATUS_ENCODING), + "{label} must be refused by the op too" + ); + } + assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), 0); +} diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go new file mode 100644 index 000000000..c876b17e9 --- /dev/null +++ b/languages/golang/stackencrypt/guest_test.go @@ -0,0 +1,901 @@ +package stackencrypt + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/stackauth" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/sys" +) + +// Tests that drive the embedded guest without a live ZeroKMS. What they +// pin, hermetically: +// +// - the module's import surface is exactly WASI plus the two transport +// functions; +// - the bridge issues the request ZeroKMS expects and maps every +// transport/HTTP outcome to the documented error; +// - every encoding this package builds — config, selectors, options, +// values, plans, sources, record trees, term inputs — is accepted by +// the guest's parsers. The guest validates all inputs before it +// consults its cipher, so on an instance that was never initialised a +// well-formed call is ErrState and a malformed one is ErrEncoding: the +// status tells which side of the boundary is wrong, with no key +// material involved; +// - hostile pointer/length pairs are statuses, never traps; +// - the client key does not survive in guest memory. +// +// Round trips through real key material need ZeroKMS and live in +// live_test.go (skipped without credentials; phase 5's harness runs them). + +const ( + testClientID = "6a70bd18-99ac-4650-b104-37eec3a15b09" + // A generated v1 client key (a recipher proxy keyset, CBOR, hex) with no + // ZeroKMS behind it: the guest parses it, and it is distinctive enough + // for the residency scan. + testClientKey = "a4627031a16b7065726d75746174696f6e90090a0d070806020f0e010503040b0c006770325f66726f6da16b7065726d75746174696f6e90000e08070c030a01050d06040f0b09026570325f746fa16b7065726d75746174696f6e9005030c0f060702000e010a0b0804090d627033a16b7065726d75746174696f6e982102010c0a182008181b061116120b070f10051509181c0d131403181a0e181d18180400181f17181e1819" +) + +func guestOrSkip(t *testing.T) []byte { + t.Helper() + wasm, err := embeddedGuest() + if err != nil { + t.Skipf("%v", err) + } + return wasm +} + +// zerokmsStub records the requests a client makes and answers them all with +// one canned response. +type zerokmsStub struct { + *httptest.Server + requests []stubRequest + status int + body string + // contentType "" means the header is absent (Go's sniffing suppressed). + contentType string +} + +type stubRequest struct { + method, path, auth, contentType, body string +} + +func newStub(t *testing.T, status int, contentType, body string) *zerokmsStub { + t.Helper() + s := &zerokmsStub{status: status, body: body, contentType: contentType} + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + s.requests = append(s.requests, stubRequest{ + method: r.Method, path: r.URL.Path, auth: r.Header.Get("Authorization"), + contentType: r.Header.Get("Content-Type"), body: string(b), + }) + if s.contentType == "" { + w.Header()["Content-Type"] = nil + } else { + w.Header().Set("Content-Type", s.contentType) + } + w.WriteHeader(s.status) + _, _ = io.WriteString(w, s.body) + })) + t.Cleanup(s.Close) + return s +} + +// testConfig is the options for a client of the test credentials against +// url. A test appends to it; a later option wins. +func testConfig(url string) []ClientOption { + return []ClientOption{WithCredentials(testCredentials(staticToken("stub-token"))), withZeroKMSURL(url)} +} + +// testCredentials is the test client id and a fresh copy of the test key, +// with token as the token source. +func testCredentials(token tokenSource) Credentials { + return newTestCredentials(testClientID, NewClientKey([]byte(testClientKey)), token) +} + +// testInit is testConfig as se_cipher_init takes it, for tests that drive +// an instance by hand. +func testInit(url string) initConfig { + return initConfig{clientID: testClientID, clientKey: NewClientKey([]byte(testClientKey)), zerokmsURL: url} +} + +func TestImportSurfaceIsWASIPlusTransport(t *testing.T) { + ctx := context.Background() + r := wazero.NewRuntime(ctx) + defer r.Close(ctx) + compiled, err := r.CompileModule(ctx, guestOrSkip(t)) + if err != nil { + t.Fatal(err) + } + defer compiled.Close(ctx) + var transportImports []string + for _, imp := range compiled.ImportedFunctions() { + module, name, _ := imp.Import() + switch module { + case "wasi_snapshot_preview1": + for _, denied := range []string{"path_", "sock_", "fd_prestat"} { + if strings.HasPrefix(name, denied) { + t.Errorf("guest imports capability-granting WASI function %s", name) + } + } + case transportModule: + transportImports = append(transportImports, name) + default: + t.Errorf("guest imports %s::%s, outside the allowed surface", module, name) + } + } + want := []string{"token_get", "transport_send"} + sort.Strings(transportImports) + if !reflect.DeepEqual(transportImports, want) { + t.Fatalf("transport imports = %v, want %v", transportImports, want) + } + for name := range map[string]bool{"se_alloc": true, "se_dealloc": true, "se_cipher_init": true, "se_shutdown": true, "se_keyset": true, "se_encrypt": true, "se_decrypt": true, "se_encrypt_element": true, "se_decrypt_element": true, "se_term": true, "se_encrypt_record": true, "se_decrypt_record": true} { + if _, ok := compiled.ExportedFunctions()[name]; !ok { + t.Errorf("guest does not export %s", name) + } + } +} + +func TestNewClientIssuesTheLoadKeysetRequest(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + _, err := NewClient(context.Background(), testConfig(stub.URL)...) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized", err) + } + if len(stub.requests) != 1 { + t.Fatalf("requests = %d, want 1 (one load-keyset)", len(stub.requests)) + } + req := stub.requests[0] + if req.method != http.MethodPost || req.auth != "Bearer stub-token" || req.contentType != "application/json" { + t.Errorf("request = %+v", req) + } + if !strings.HasSuffix(req.path, "load-keyset") { + t.Errorf("path = %q, want a load-keyset endpoint", req.path) + } + if !strings.HasPrefix(req.body, "{") { + t.Errorf("body %q is not JSON", req.body) + } + if strings.Contains(req.body, testClientKey) { + t.Error("the client key was sent over the wire") + } +} + +func TestTransportOutcomesMapToErrors(t *testing.T) { + guestOrSkip(t) + cases := []struct { + name string + status int + contentType string + body string + want error + }{ + {"401", http.StatusUnauthorized, "", "nope", ErrUnauthorized}, + {"403", http.StatusForbidden, "", "not permitted", ErrForbidden}, + {"404", http.StatusNotFound, "", "missing", ErrNotFound}, + {"409", http.StatusConflict, "", "exists", ErrConflict}, + {"500", http.StatusInternalServerError, "", "boom", ErrKMS}, + {"200 html", http.StatusOK, "text/html", "<html>gateway</html>", ErrKMS}, + {"200 not json", http.StatusOK, "application/json", "not json", ErrKMS}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stub := newStub(t, tc.status, tc.contentType, tc.body) + _, err := NewClient(context.Background(), testConfig(stub.URL)...) + if !errors.Is(err, tc.want) { + t.Fatalf("NewClient: %v, want %v", err, tc.want) + } + }) + } + t.Run("connection refused", func(t *testing.T) { + stub := newStub(t, http.StatusOK, "application/json", "{}") + url := stub.URL + stub.Close() + _, err := NewClient(context.Background(), testConfig(url)...) + if !errors.Is(err, ErrTransport) { + t.Fatalf("NewClient: %v, want ErrTransport", err) + } + }) + t.Run("no token", func(t *testing.T) { + stub := newStub(t, http.StatusOK, "application/json", "{}") + cfg := testConfig(stub.URL) + vaultDown := errors.New("vault down") + cfg = append(cfg, WithCredentials(testCredentials(tokenFunc(func(context.Context) (string, error) { return "", vaultDown })))) + _, err := NewClient(context.Background(), cfg...) + if err == nil { + t.Fatal("NewClient succeeded with no token") + } + // The guest reports only that token_get failed; the client attaches + // what the token source said. + if !errors.Is(err, vaultDown) { + t.Fatalf("NewClient: %v, want the token source's error", err) + } + if len(stub.requests) != 0 { + t.Fatalf("a request was made without a token: %+v", stub.requests) + } + }) +} + +func TestRoundTripperFailureIsTransport(t *testing.T) { + guestOrSkip(t) + cfg := testConfig("http://zerokms.invalid") + cfg = append(cfg, WithTransport(roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("no route") + }))) + _, err := NewClient(context.Background(), cfg...) + if !errors.Is(err, ErrTransport) { + t.Fatalf("NewClient: %v, want ErrTransport", err) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// An interrupted call closes the module (WithCloseOnContextDone); the +// client must then be closed rather than a wedge or a runtime error. +func TestInterruptedCallClosesTheClient(t *testing.T) { + t.Run("deadline during a request", func(t *testing.T) { + guestOrSkip(t) + cfg := testConfig("http://zerokms.invalid") + cfg = append(cfg, WithTransport(roundTripFunc(func(r *http.Request) (*http.Response, error) { + <-r.Context().Done() + return nil, r.Context().Err() + }))) + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, err := NewClient(ctx, cfg...) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("NewClient: %v, want the deadline", err) + } + }) + t.Run("closed module is ErrState", func(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + // What the runtime does to the module when a call's context ends. + if err := c.inst.module.CloseWithExitCode(ctx, sys.ExitCodeContextCanceled); err != nil { + t.Fatal(err) + } + if _, err := c.Keyset(KeysetName("k")).KeysetID(ctx); !errors.Is(err, ErrState) { + t.Fatalf("Keyset on a closed module: %v, want ErrState", err) + } + if err := c.Close(); err != nil { + t.Fatalf("Close after interruption: %v", err) + } + // The close must reach the runtime. An interrupted call closes the + // module and marks the client closed; a Close that treated that as + // "already done" would leave the runtime and the host module it + // carries allocated for the life of the process. + if !c.released { + t.Error("Close after interruption left the runtime unreleased") + } + if m := c.inst.runtime.Module(transportModule); m != nil { + t.Errorf("host module %s is still registered after Close", transportModule) + } + // Still idempotent. + if err := c.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } + }) +} + +// A response the host would have to buffer without bound is refused as a +// transport failure, whether the size is announced or streamed. +func TestOversizedResponseIsTransport(t *testing.T) { + guestOrSkip(t) + respond := func(length int64, body io.Reader) roundTripFunc { + return func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"application/json"}}, + ContentLength: length, + Body: io.NopCloser(body), + }, nil + } + } + for name, rt := range map[string]roundTripFunc{ + "announced": respond(maxResponseBytes+1, strings.NewReader("{}")), + "streamed": respond(-1, io.MultiReader(strings.NewReader("{"), &zeros{n: maxResponseBytes})), + } { + t.Run(name, func(t *testing.T) { + cfg := testConfig("http://zerokms.invalid") + cfg = append(cfg, WithTransport(rt)) + _, err := NewClient(context.Background(), cfg...) + if !errors.Is(err, ErrTransport) { + t.Fatalf("NewClient: %v, want ErrTransport", err) + } + }) + } +} + +// A status that would wrap in the guest's i32 — here to 200 — is refused +// as a transport failure. +func TestOutOfRangeStatusIsTransport(t *testing.T) { + guestOrSkip(t) + cases := map[string]int{"negative": -200, "two digits": 99} + if strconv.IntSize == 64 { + wraps := int64(1<<32 + 200) + cases["wraps to 200"] = int(wraps) + } + for name, status := range cases { + t.Run(name, func(t *testing.T) { + cfg := testConfig("http://zerokms.invalid") + cfg = append(cfg, WithTransport(roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader("{}")), + }, nil + }))) + _, err := NewClient(context.Background(), cfg...) + if !errors.Is(err, ErrTransport) { + t.Fatalf("NewClient: %v, want ErrTransport", err) + } + }) + } +} + +// A bare empty part is an empty context, which the guest refuses at the +// boundary — so the constructor refuses it first, rather than handing back +// a Context that fails every call it is used in. A list is empty only when +// every part is, so With may still carry one. +func TestEmptyContextIsRefusedAtTheRoot(t *testing.T) { + for name, part := range map[string]any{"string": "", "bytes": []byte{}} { + t.Run(name, func(t *testing.T) { + if _, err := NewContext(part); err == nil { + t.Fatal("NewContext accepted an empty part") + } + func() { + defer func() { + if recover() == nil { + t.Error("MustContext did not panic on an empty part") + } + }() + _ = MustContext(part) + }() + }) + } + // The rule is the tree's: an empty part beside a non-empty one is a + // context the guest takes, so With must not inherit the root's check. + mixed, err := MustContext("users/age").With("") + if err != nil { + t.Fatalf("With(empty): %v", err) + } + guestOrSkip(t) + ctx := context.Background() + // No cipher on a raw instance, so a context the guest accepts reaches + // the state check — ErrState here means the context itself passed, + // where a refused one is ErrEncoding before it. + if _, err := rawInstance(t).DefaultKeyset().Term(ctx, uint32(34), mixed, Equality); !errors.Is(err, ErrState) { + t.Fatalf("Term under [non-empty, empty]: %v, want ErrState (the context accepted)", err) + } +} + +// A body that fails partway through is a transport failure, not a partial +// response the guest is handed. io.ReadAll returns the bytes it managed to +// read alongside the error; those bytes are a fragment of a ZeroKMS reply +// and are wiped before the error goes back (see transport.perform) — the +// wipe is not observable from here, but the verdict is. +func TestInterruptedResponseBodyIsTransport(t *testing.T) { + guestOrSkip(t) + cfg := testConfig("http://zerokms.invalid") + cfg = append(cfg, WithTransport(roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"application/json"}}, + ContentLength: -1, + Body: io.NopCloser(io.MultiReader( + strings.NewReader(`{"partial":"`), + &failingReader{err: io.ErrUnexpectedEOF}, + )), + }, nil + }))) + if _, err := NewClient(context.Background(), cfg...); !errors.Is(err, ErrTransport) { + t.Fatalf("NewClient: %v, want ErrTransport", err) + } +} + +// A RoundTripper may keep reading the request body, and close it, in +// another goroutine after RoundTrip has returned — on the error path too. +// The host copy of the body must therefore survive until the transport +// closes it: a wipe on RoundTrip's return would race the send and put a +// truncated or zeroed request on the wire. Here the drain happens strictly +// after the whole guest call has returned, and must still see the request. +func TestRequestBodyOutlivesTheRoundTrip(t *testing.T) { + guestOrSkip(t) + returned := make(chan struct{}) + type drained struct { + req *http.Request + body []byte + err error + } + done := make(chan drained, 1) + cfg := testConfig("http://zerokms.invalid") + cfg = append(cfg, WithTransport(roundTripFunc(func(r *http.Request) (*http.Response, error) { + go func() { + <-returned + b, err := io.ReadAll(r.Body) + _ = r.Body.Close() + done <- drained{req: r, body: b, err: err} + }() + return nil, errors.New("connection reset") + }))) + _, err := NewClient(context.Background(), cfg...) + if !errors.Is(err, ErrTransport) { + t.Fatalf("NewClient: %v, want ErrTransport", err) + } + close(returned) + d := <-done + if d.err != nil { + t.Fatalf("reading the body after RoundTrip returned: %v", d.err) + } + if !json.Valid(d.body) || !bytes.Contains(d.body, []byte(testClientID)) { + t.Fatalf("body read after RoundTrip returned is not the request: %q", d.body) + } + // The length is declared, so the transport sends Content-Length rather + // than chunking a body it cannot size. + if d.req.ContentLength != int64(len(d.body)) { + t.Errorf("ContentLength = %d, want %d", d.req.ContentLength, len(d.body)) + } + // And once closed, the host copy is gone. + rb, ok := d.req.Body.(*requestBody) + if !ok { + t.Fatalf("request body is %T, want *requestBody", d.req.Body) + } + if !bytes.Equal(rb.buf, make([]byte, len(rb.buf))) { + t.Error("request body was not wiped on Close") + } +} + +// failingReader fails every read. +type failingReader struct{ err error } + +func (f *failingReader) Read([]byte) (int, error) { return 0, f.err } + +// zeros reads n zero bytes. +type zeros struct{ n int } + +func (z *zeros) Read(p []byte) (int, error) { + if z.n == 0 { + return 0, io.EOF + } + if len(p) > z.n { + p = p[:z.n] + } + clear(p) + z.n -= len(p) + return len(p), nil +} + +func TestConfigValidation(t *testing.T) { + ctx := context.Background() + wiped := NewClientKey([]byte(testClientKey)) + wiped.Wipe() + for name, tc := range map[string]struct { + id string + key *ClientKey + token tokenSource + cache int + }{ + "no token": {id: testClientID, key: NewClientKey([]byte(testClientKey))}, + "no client id": {key: NewClientKey([]byte(testClientKey)), token: staticToken("t")}, + "no key": {id: testClientID, token: staticToken("t")}, + "empty key": {id: testClientID, key: NewClientKey(nil), token: staticToken("t")}, + "wiped key": {id: testClientID, key: wiped, token: staticToken("t")}, + "negative cache": {id: testClientID, key: NewClientKey([]byte(testClientKey)), token: staticToken("t"), cache: -1}, + } { + cfg := []ClientOption{WithCredentials(newTestCredentials(tc.id, tc.key, tc.token)), WithKeysetCacheSize(tc.cache)} + if _, err := NewClient(ctx, cfg...); err == nil { + t.Errorf("%s: NewClient succeeded", name) + } + // A refused config consumes the key too: the caller is never handed + // live material back with the error. + if !tc.key.IsZero() { + t.Errorf("%s: the key still holds material after NewClient refused the config", name) + } + } + // Malformed values the guest refuses: no request is made. + guestOrSkip(t) + for name, option := range map[string]ClientOption{ + "client id not a uuid": WithCredentials(newTestCredentials("acme", NewClientKey([]byte(testClientKey)), staticToken("t"))), + "key not hex": WithCredentials(newTestCredentials(testClientID, NewClientKey([]byte("zz")), staticToken("t"))), + "bad url": withZeroKMSURL("not a url"), + } { + stub := newStub(t, http.StatusOK, "application/json", "{}") + _, err := NewClient(ctx, append(testConfig(stub.URL), option)...) + if !errors.Is(err, ErrEncoding) { + t.Errorf("%s: %v, want ErrEncoding", name, err) + } + if len(stub.requests) != 0 { + t.Errorf("%s: a request was made for a malformed config", name) + } + } +} + +// The client key is consumed by NewClient: whatever the outcome, the bytes +// it was built from are zero once NewClient returns, the key reports +// itself empty, and the credentials never print the material under any +// verb. +// +// The outcome exercised here is the guest's init failing (a refused +// token); the refused-config outcomes are in TestConfigValidation, and +// the successful one in the live test, which is the only place a client +// can be built against a real load-keyset response. The wipe precedes the +// init call, so the three paths share it. +func TestClientKeyIsConsumedAndNeverPrinted(t *testing.T) { + material := []byte(testClientKey) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + key := NewClientKey(material) + creds := newTestCredentials(testClientID, key, staticToken("stub-token")) + cfg := append(testConfig(stub.URL), WithCredentials(creds)) + // Resolving consumes the credentials, so the printed resolution is a + // separate set's, over another copy of the key. + resolved, err := newTestCredentials(testClientID, NewClientKey([]byte(testClientKey)), staticToken("stub-token")).resolve(context.Background(), resolveOptions{}) + if err != nil { + t.Fatal(err) + } + defer resolved.ClientKey.Wipe() + // %x and %d reach a struct's fields without asking a Stringer; the + // key's Formatter answers for them. + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%d"} { + for what, v := range map[string]any{"Credentials": creds, "ResolvedCredentials": *resolved} { + out := fmt.Sprintf(verb, v) + if strings.Contains(out, testClientKey[:16]) || strings.Contains(out, hex.EncodeToString(material[:8])) { + t.Errorf("%s under %s prints the key: %q", what, verb, out) + } + } + } + guestOrSkip(t) + if _, err := NewClient(context.Background(), cfg...); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized", err) + } + if !key.IsZero() { + t.Error("the key still holds material after NewClient") + } + for i, b := range material { + if b != 0 { + t.Fatalf("byte %d of the key material was not wiped", i) + } + } + // A consumed key does not make a second client, says so, and asks + // nothing of ZeroKMS trying. + before := len(stub.requests) + if _, err := NewClient(context.Background(), cfg...); !errors.Is(err, ErrCredentialsConsumed) { + t.Errorf("NewClient with a consumed key: %v, want ErrCredentialsConsumed before any request", err) + } + if len(stub.requests) != before { + t.Errorf("a consumed key made %d request(s)", len(stub.requests)-before) + } +} + +// rawInstance is a guest that was never initialised: every well-formed +// operation is ErrState there, every malformed one ErrEncoding. +func rawInstance(t *testing.T) *Client { + t.Helper() + ctx := context.Background() + inst, err := newInstance(ctx, guestOrSkip(t), &transport{rt: http.DefaultTransport, token: staticToken("t")}, guest.BestEffort) + if err != nil { + t.Fatal(err) + } + c := newClient(inst, nil) + t.Cleanup(func() { _ = c.Close() }) + return c +} + +// A structurally valid stack-encrypt leaf (the frozen layout: version, +// keyset id, IV, tag length, tag, ciphertext) with no real key behind it. +var fixtureLeaf = mustHex("016b65797365742d666978747572653136303132333435363738396162636465660300aabbccdeadbeef") + +func mustHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return b +} + +type recordRow struct { + Age uint32 `stash:"context=users/age,index=eq;ore"` + Email string `stash:"context=users/email,index=eq;match"` +} + +// A record decrypted under a plan that names a field it does not carry is +// refused on the host, with the field named, before the guest is asked. +func TestMismatchedPlanIsRefusedBeforeTheGuest(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + record := EncryptedRecord{"Age": {Ciphertext: Sealed(fixtureLeaf)}} + plan, err := NewPlan(FieldPlan{Field: "Email", Name: "email", Context: "users/email"}) + if err != nil { + t.Fatal(err) + } + var out []struct{ Email string } + err = c.DecryptRecords(ctx, []EncryptedRecord{record}, &out, WithPlan(plan)) + if err == nil || errors.Is(err, ErrState) || !strings.Contains(err.Error(), `no ciphertext for field "email"`) { + t.Fatalf("mismatched plan: %v, want the host's refusal naming the field", err) + } +} + +// Every encoding the package builds reaches the guest's own parsers and +// passes them: the uninitialised instance answers ErrState only after it +// has validated all inputs. +func TestGuestAcceptsEveryEncodingThisPackageBuilds(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + def := c.DefaultKeyset() + named := c.Keyset(KeysetName("acme")) + byID := c.Keyset(KeysetID{9}) + ct := map[string]any{"name": Sealed(fixtureLeaf), "note": vcvalue.Plain{V: "clear"}} + record := EncryptedRecord{ + "Age": {Ciphertext: Sealed(fixtureLeaf), Equality: EqualityTerm{1}, Ore: OreTerm{2}}, + "Email": {Ciphertext: Sealed(fixtureLeaf)}, + } + rows := []recordRow{{Age: 1, Email: "a@b.c"}} + var out []recordRow + var one recordRow + type untaggedRow struct { + Age uint32 + Email string + } + plan, err := NewPlan( + FieldPlan{Field: "Age", Context: "users/age", Terms: []TermKind{Equality, Ore}}, + FieldPlan{Field: "Email", Context: "users/email", Terms: []TermKind{Equality, Match}}, + ) + if err != nil { + t.Fatal(err) + } + var planned []untaggedRow + calls := map[string]func() error{ + "KeysetID by name": func() error { _, err := named.KeysetID(ctx); return err }, + "KeysetID by id": func() error { _, err := byID.KeysetID(ctx); return err }, + "KeysetID default": func() error { _, err := def.KeysetID(ctx); return err }, + "Encrypt": func() error { _, err := def.Encrypt(ctx, map[string]any{"a": 1}, []byte("aad")); return err }, + "EncryptElement": func() error { _, err := named.EncryptElement(ctx, "row", nil); return err }, + "Decrypt bound": func() error { _, err := byID.Decrypt(ctx, ct, nil); return err }, + "Decrypt any": func() error { _, err := c.Decrypt(ctx, ct, []byte("aad")); return err }, + "DecryptElement any": func() error { _, err := c.DecryptElement(ctx, Sealed(fixtureLeaf), nil); return err }, + "Term equality": func() error { _, err := def.Term(ctx, uint32(34), MustContext("users/age"), Equality); return err }, + "Term match": func() error { _, err := named.Term(ctx, "alice", MustContext("users/email"), Match); return err }, + "Term ore extended": func() error { + c, _ := MustContext("users/age").With(uint64(7)) + _, err := byID.Term(ctx, 1.5, c, Ore) + return err + }, + "Term ope bytes": func() error { _, err := def.Term(ctx, []byte{1}, MustContext("k"), Ope); return err }, + "EncryptRecords": func() error { _, err := def.EncryptRecords(ctx, rows); return err }, + "EncryptRecords ext": func() error { _, err := named.EncryptRecords(ctx, &rows, ExtendContext(uint64(7), "eu")); return err }, + "EncryptRecord": func() error { _, err := byID.EncryptRecord(ctx, rows[0]); return err }, + "DecryptRecords bound": func() error { return def.DecryptRecords(ctx, []EncryptedRecord{record}, &out) }, + "DecryptRecords any": func() error { return c.DecryptRecords(ctx, []EncryptedRecord{record, record}, &out) }, + "DecryptRecord any": func() error { return c.DecryptRecord(ctx, record, &one, ExtendContext("x")) }, + "EncryptRecords plan": func() error { + _, err := def.EncryptRecords(ctx, []untaggedRow{{Age: 1, Email: "a@b.c"}}, WithPlan(plan)) + return err + }, + "DecryptRecords plan": func() error { + return c.DecryptRecords(ctx, []EncryptedRecord{record}, &planned, WithPlan(plan), ExtendContext(uint64(7))) + }, + } + for name, call := range calls { + if err := call(); !errors.Is(err, ErrState) { + t.Errorf("%s: %v, want ErrState (every input parsed, no cipher)", name, err) + } + } +} + +// The inputs the guest must refuse are refused before it looks for a +// cipher: ErrEncoding, not ErrState, on the same uninitialised instance. +func TestGuestRefusesMalformedInputsBeforeState(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + def := c.DefaultKeyset() + type badRow struct { + Age float64 `stash:"context=users/age,index=eq"` + } + calls := map[string]func() error{ + "float under equality": func() error { _, err := def.Term(ctx, 1.5, MustContext("k"), Equality); return err }, + "integer under match": func() error { _, err := def.Term(ctx, 1, MustContext("k"), Match); return err }, + "container as term value": func() error { _, err := def.Term(ctx, []any{1}, MustContext("k"), Ore); return err }, + // NewContext refuses this one now (see + // TestEmptyContextIsRefusedAtTheRoot); built by hand so the guest's + // own boundary check stays covered from this side too. + "empty context part": func() error { + _, err := def.Term(ctx, 1, Context{node: ""}, Equality) + return err + }, + "unknown term kind": func() error { _, err := def.Term(ctx, 1, MustContext("k"), TermKind(9)); return err }, + "name with spaces": func() error { _, err := c.Keyset(KeysetName("not a name")).KeysetID(ctx); return err }, + "empty name": func() error { _, err := c.Keyset(KeysetName("")).KeysetID(ctx); return err }, + "any as a keyset": func() error { _, err := c.Keyset(anyKeyset{}).KeysetID(ctx); return err }, + "float under eq in plan": func() error { _, err := def.EncryptRecords(ctx, []badRow{{1.5}}); return err }, + "malformed leaf": func() error { + _, err := c.Decrypt(ctx, Sealed{1, 2, 3}, nil) + return err + }, + "record without c": func() error { + return c.DecryptRecord(ctx, EncryptedRecord{"Age": {Equality: EqualityTerm{1}}, "Email": {Ciphertext: Sealed(fixtureLeaf)}}, new(recordRow)) + }, + } + for name, call := range calls { + err := call() + if errors.Is(err, ErrState) { + t.Errorf("%s: reached the cipher (ErrState); must be refused at parse", name) + } else if err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +func TestClosedClientIsState(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + if err := c.Close(); err != nil { + t.Fatal(err) + } + if err := c.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } + if _, err := c.DefaultKeyset().Encrypt(ctx, "x", nil); !errors.Is(err, ErrState) { + t.Fatalf("Encrypt after Close: %v", err) + } +} + +func TestHostilePointerLengthPairsAreStatusesNotTraps(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + inst := c.inst + // A null pointer with a nonzero length must fail closed. + res, err := inst.cipherInit.Call(ctx, 0, 64) + if err != nil { + t.Fatalf("init with null pointer trapped: %v", err) + } + if _, _, cerr := guest.PackedResult(res[0]); !errors.Is(cerr, ErrEncoding) { + t.Fatalf("null pointer: %v, want ErrEncoding", cerr) + } + staged, err := inst.exports.AllocWrite(ctx, inst.module, bytes.Repeat([]byte{0x2a}, 64)) + if err != nil { + t.Fatal(err) + } + defer inst.exports.Free(ctx, staged) + for _, hostile := range []uint64{0x7FFF_FFF0, 0xFFFF_FFFF} { + for name, fn := range map[string]func() ([]uint64, error){ + "se_cipher_init": func() ([]uint64, error) { return inst.cipherInit.Call(ctx, uint64(staged.Ptr), hostile) }, + "se_keyset": func() ([]uint64, error) { return inst.keyset.Call(ctx, uint64(staged.Ptr), hostile) }, + "se_encrypt": func() ([]uint64, error) { + return inst.encrypt.Call(ctx, uint64(staged.Ptr), hostile, 0, 0, uint64(staged.Ptr), 4) + }, + } { + res, err := fn() + if err != nil { + t.Fatalf("%s with len %#x trapped: %v", name, hostile, err) + } + if _, _, cerr := guest.PackedResult(res[0]); !errors.Is(cerr, ErrEncoding) { + t.Errorf("%s with len %#x: %v, want ErrEncoding", name, hostile, cerr) + } + } + } + // An unknown or mismatched free is a no-op, not a trap. + if _, err := inst.exports.Dealloc.Call(ctx, uint64(staged.Ptr)+1, 1); err != nil { + t.Fatalf("dealloc of an unknown pointer trapped: %v", err) + } + if _, err := inst.exports.Dealloc.Call(ctx, uint64(staged.Ptr), 1); err != nil { + t.Fatalf("dealloc with a mismatched length trapped: %v", err) + } + // The instance still works. + if _, err := c.DefaultKeyset().Encrypt(ctx, "alive", nil); !errors.Is(err, ErrState) { + t.Fatalf("instance poisoned: %v", err) + } +} + +// The client key crosses into guest memory once, in the config buffer, +// which the guest wipes before any request; the Go-side transport copy is +// wiped too. Neither the hex form nor its decoded bytes may remain in +// linear memory after NewClient returns — success or failure. +func TestClientKeyDoesNotRemainInGuestMemory(t *testing.T) { + guestOrSkip(t) + // A body long enough that a hit is not a coincidence of four bytes. + const errorBody = "refused-4111-9f8e7d6c5b4a-residency-probe" + stub := newStub(t, http.StatusUnauthorized, "", errorBody) + // Keep the instance to scan it: build the client by hand so a failed + // init does not tear it down first. + ctx := context.Background() + encoded, err := encodeConfig(testInit(stub.URL)) + if err != nil { + t.Fatal(err) + } + tr := &transport{rt: http.DefaultTransport, token: staticToken("stub-token")} + inst, err := newInstance(ctx, guestOrSkip(t), tr, guest.BestEffort) + if err != nil { + t.Fatal(err) + } + defer func() { _ = inst.release() }() + _, err = inst.call(ctx, inst.cipherInit, buf(encoded)) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("init: %v", err) + } + mem := inst.module.Memory() + view, ok := mem.Read(0, mem.Size()) + if !ok { + t.Fatal("cannot read guest memory") + } + // The response body is what ZeroKMS answered, placed in guest memory + // by the transport and wiped by the guest's registry when the call + // returns; the bearer token and the response headers travel the same + // way. Nothing a call staged may outlive it. + for name, needle := range map[string][]byte{ + "key hex": []byte(testClientKey), + "key bytes": mustHex(testClientKey), + "bearer": []byte("stub-token"), + "response body": []byte(errorBody), + } { + if n := bytes.Count(view, needle); n != 0 { + t.Errorf("%s found %d times in guest memory after init", name, n) + } + } +} + +func TestTransportSendCounterAndResponseHeaders(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "text/plain", "nope") + tr := &transport{rt: http.DefaultTransport, token: staticToken("stub-token")} + ctx := context.Background() + inst, err := newInstance(ctx, guestOrSkip(t), tr, guest.BestEffort) + if err != nil { + t.Fatal(err) + } + defer func() { _ = inst.release() }() + encoded, _ := encodeConfig(testInit(stub.URL)) + if _, err := inst.call(ctx, inst.cipherInit, buf(encoded)); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("init: %v", err) + } + if n := tr.sends.Load(); n != 1 { + t.Fatalf("transport sends = %d, want 1", n) + } +} + +func ExampleNewClient() { + // With no options, NewClient uses AutoCredentials. To supply the + // credentials yourself, the token comes from a stackauth strategy — + // here an access key; live_test.go has a real round trip. The caller + // opened the store and the strategy, and closes them after the client. + ctx := context.Background() + err := func() error { + store, err := stackauth.OpenWithoutProfile(ctx) + if err != nil { + return err + } + defer store.Close() + strategy, err := store.AccessKey(ctx, "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY", "CSAK...") + if err != nil { + return err + } + defer strategy.Close() + client, err := NewClient(ctx, WithCredentials(NewCredentials( + "6a70bd18-99ac-4650-b104-37eec3a15b09", + NewClientKey([]byte("...")), // not a real key: NewClient refuses it + strategy, + ))) + if err != nil { + return err + } + return client.Close() + }() + fmt.Println(err != nil) + // Output: true +} diff --git a/languages/golang/stackencrypt/keyset.go b/languages/golang/stackencrypt/keyset.go new file mode 100644 index 000000000..1131d0d10 --- /dev/null +++ b/languages/golang/stackencrypt/keyset.go @@ -0,0 +1,82 @@ +package stackencrypt + +import ( + "encoding/hex" + "fmt" +) + +// KeysetSelector names the keyset a call binds to, as Rust's IdentifiedBy +// does: [KeysetName] or [KeysetID]. Every variant is spelled, there is no +// empty-string or nil sentinel; the default keyset is not a selector but +// [Client.DefaultKeyset], exactly as it is a method and not an IdentifiedBy +// variant in Rust. Names follow ZeroKMS's rules +// (non-empty, at most 64 bytes, of A-Z a-z 0-9 _ - /), checked by the +// guest before any request is made; ZeroKMS itself never issues a +// UUID-shaped name, so a name and an id cannot be confused. +type KeysetSelector interface { + // selector renders the tagged object the guest parses. + selector() map[string]any +} + +// KeysetName selects a keyset by name. Its first use on a client is one +// ZeroKMS round trip; the binding is cached by the guest for a bounded +// window, after which the name is resolved again. +type KeysetName string + +func (n KeysetName) selector() map[string]any { return map[string]any{"name": string(n)} } + +// KeysetID is a keyset's UUID, the identity a sealed leaf carries. It +// selects a keyset by id; ids are never re-resolved. +type KeysetID [16]byte + +func (id KeysetID) selector() map[string]any { return map[string]any{"id": id[:]} } + +// String renders the id in canonical hyphenated form. +func (id KeysetID) String() string { + var b [36]byte + hex.Encode(b[:8], id[:4]) + b[8] = '-' + hex.Encode(b[9:13], id[4:6]) + b[13] = '-' + hex.Encode(b[14:18], id[6:8]) + b[18] = '-' + hex.Encode(b[19:23], id[8:10]) + b[23] = '-' + hex.Encode(b[24:], id[10:]) + return string(b[:]) +} + +// ParseKeysetID parses a canonical hyphenated UUID. +func ParseKeysetID(s string) (KeysetID, error) { + var id KeysetID + if len(s) != 36 || s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' { + return id, fmt.Errorf("stackencrypt: %q is not a UUID", s) + } + hexed := s[:8] + s[9:13] + s[14:18] + s[19:23] + s[24:] + if _, err := hex.Decode(id[:], []byte(hexed)); err != nil { + return id, fmt.Errorf("stackencrypt: %q is not a UUID", s) + } + return id, nil +} + +// defaultKeyset is the guest's spelling of the client's default keyset — +// the one a ZeroKMS administrator set for this client. Selecting it is +// never a round trip. Not exported: the default is [Client.DefaultKeyset], +// a method, so there is no value an importer could reassign or pass by +// mistake, and no config key that appeared to override what is the +// server's to say. +type defaultKeyset struct{} + +func (defaultKeyset) selector() map[string]any { return map[string]any{"default": map[string]any{}} } + +// anyKeyset is the opening-only selector: open every leaf under whichever +// keyset it was sealed with. Not exported — the Client's own decrypt +// methods are its spelling. +type anyKeyset struct{} + +func (anyKeyset) selector() map[string]any { return map[string]any{"any": map[string]any{}} } + +// options renders the per-call options object the guest parses. +func options(sel KeysetSelector) map[string]any { + return map[string]any{"keyset": sel.selector()} +} diff --git a/languages/golang/stackencrypt/leaf.go b/languages/golang/stackencrypt/leaf.go new file mode 100644 index 000000000..702dbbb66 --- /dev/null +++ b/languages/golang/stackencrypt/leaf.go @@ -0,0 +1,122 @@ +package stackencrypt + +import ( + "database/sql/driver" + "fmt" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" +) + +// Sealed is one encrypted leaf: the frozen stack-encrypt storage encoding +// (version, keyset id, IV, ZeroKMS tag, ciphertext), exactly what a +// database column holds. It is a distinct type from vcvalue.Sealed on +// purpose: a stack-encrypt leaf is not decryptable by vitaminc-encrypt and +// must never scan or marshal where one belongs. +type Sealed []byte + +// SealedNone is the authenticated marker for an absent value (a nil +// pointer, a Null) inside a ciphertext. +type SealedNone []byte + +// SealedEmptySeq is the authenticated marker for an empty sequence. +type SealedEmptySeq []byte + +// SealedEmptyMap is the authenticated marker for an empty map. +type SealedEmptyMap []byte + +// Value implements driver.Valuer, binding the leaf as a byte column. +func (s Sealed) Value() (driver.Value, error) { return []byte(s), nil } + +// Value implements driver.Valuer. +func (s SealedNone) Value() (driver.Value, error) { return []byte(s), nil } + +// Value implements driver.Valuer. +func (s SealedEmptySeq) Value() (driver.Value, error) { return []byte(s), nil } + +// Value implements driver.Valuer. +func (s SealedEmptyMap) Value() (driver.Value, error) { return []byte(s), nil } + +// Scan implements sql.Scanner, loading a leaf from a byte column. +func (s *Sealed) Scan(src any) error { + b, err := scanBytes("Sealed", src) + *s = b + return err +} + +// Scan implements sql.Scanner. +func (s *SealedNone) Scan(src any) error { + b, err := scanBytes("SealedNone", src) + *s = b + return err +} + +// Scan implements sql.Scanner. +func (s *SealedEmptySeq) Scan(src any) error { + b, err := scanBytes("SealedEmptySeq", src) + *s = b + return err +} + +// Scan implements sql.Scanner. +func (s *SealedEmptyMap) Scan(src any) error { + b, err := scanBytes("SealedEmptyMap", src) + *s = b + return err +} + +// scanBytes copies a driver byte value: drivers may reuse the source slice +// after Scan returns. +func scanBytes(kind string, src any) ([]byte, error) { + switch v := src.(type) { + case []byte: + out := make([]byte, len(v)) + copy(out, v) + return out, nil + case string: + return []byte(v), nil + case nil: + return nil, fmt.Errorf("stackencrypt: cannot scan NULL into %s", kind) + default: + return nil, fmt.Errorf("stackencrypt: cannot scan %T into %s", src, kind) + } +} + +// leaves is the vcffi.LeafSet of this binding's leaf types. +var leaves = vcffi.LeafSet{ + Classify: func(v any) (vcffi.LeafKind, []byte, bool) { + switch n := v.(type) { + case Sealed: + return vcffi.LeafSingle, n, true + case SealedNone: + return vcffi.LeafNone, n, true + case SealedEmptySeq: + return vcffi.LeafEmptySeq, n, true + case SealedEmptyMap: + return vcffi.LeafEmptyMap, n, true + default: + return 0, nil, false + } + }, + Make: func(kind vcffi.LeafKind, bytes []byte) any { + switch kind { + case vcffi.LeafSingle: + return Sealed(bytes) + case vcffi.LeafNone: + return SealedNone(bytes) + case vcffi.LeafEmptySeq: + return SealedEmptySeq(bytes) + case vcffi.LeafEmptyMap: + return SealedEmptyMap(bytes) + default: + return nil + } + }, +} + +func marshalCipherText(v any) ([]byte, error) { + return vcffi.MarshalCipherText(leaves, v) +} + +func unmarshalCipherText(buf []byte) (any, error) { + return vcffi.UnmarshalCipherText(leaves, buf) +} diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go new file mode 100644 index 000000000..9a49bee12 --- /dev/null +++ b/languages/golang/stackencrypt/live_test.go @@ -0,0 +1,385 @@ +package stackencrypt + +import ( + "bytes" + "context" + "errors" + "os" + "reflect" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/stackauth" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// Round trips through real ZeroKMS key material. No CI harness runs these +// yet: one that boots zerokms-server and exports the variables below is +// tracked in CIP-4024. Until then they run locally when the variables are +// set (from a gitignored mise.local.toml, say), and each test is skipped +// unless its own are: +// +// - STACK_ENCRYPT_TEST_CLIENT_ID, STACK_ENCRYPT_TEST_CLIENT_KEY: the +// seeded client (every live test); +// - STACK_ENCRYPT_TEST_CLIENT_ACCESS_KEY, STACK_ENCRYPT_TEST_WORKSPACE_CRN: +// an access key and its workspace, exchanged for a token (every live +// test) — by a stackauth access-key strategy given to NewCredentials +// (liveClient), and by AutoCredentials from the environment +// (TestLiveAutoCredentialsFromTheEnvironment). There is no raw-token +// variable: the client takes tokens only from stackauth strategies; +// - STACK_ENCRYPT_TEST_ZEROKMS_URL (optional): the ZeroKMS endpoint, else +// the token's services claim; +// - STACK_ENCRYPT_TEST_CTS_HOST (optional): the authentication endpoint +// the access key is exchanged at, else discovery from the workspace CRN; +// - STACK_ENCRYPT_TEST_OTHER_KEYSET (optional): a second keyset's name. + +func liveClient(t *testing.T) *Client { + t.Helper() + clientID, clientKey := os.Getenv("STACK_ENCRYPT_TEST_CLIENT_ID"), os.Getenv("STACK_ENCRYPT_TEST_CLIENT_KEY") + accessKey, crn := os.Getenv("STACK_ENCRYPT_TEST_CLIENT_ACCESS_KEY"), os.Getenv("STACK_ENCRYPT_TEST_WORKSPACE_CRN") + url := os.Getenv("STACK_ENCRYPT_TEST_ZEROKMS_URL") + if clientID == "" || clientKey == "" || accessKey == "" || crn == "" { + t.Skip("STACK_ENCRYPT_TEST_{CLIENT_ID,CLIENT_KEY,CLIENT_ACCESS_KEY,WORKSPACE_CRN} not set") + } + guestOrSkip(t) + authGuestOrSkip(t) + // The explicit path: the caller opens the store and the strategy, and + // closes them after the client (cleanups run last-registered first). + store, err := stackauth.OpenWithoutProfile(t.Context()) + if err != nil { + t.Fatalf("stackauth.OpenWithoutProfile: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + var strategyOpts []stackauth.StrategyOption + if cts := os.Getenv("STACK_ENCRYPT_TEST_CTS_HOST"); cts != "" { + strategyOpts = append(strategyOpts, stackauth.WithAuthBaseURL(cts)) + } + strategy, err := store.AccessKey(t.Context(), crn, accessKey, strategyOpts...) + if err != nil { + t.Fatalf("stackauth access-key strategy: %v", err) + } + t.Cleanup(func() { + if err := strategy.Close(); err != nil { + t.Errorf("strategy.Close: %v", err) + } + }) + material := []byte(clientKey) + key := NewClientKey(material) + // The credentials a successful NewClient resolved are released by the + // client's Close, once: the wiring only a real load-keyset response can + // reach. NewCredentials itself holds nothing to release — the strategy + // is the caller's — so the spy adds a Close to count. + var released int + creds := credentialsFunc(func(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + r, err := NewCredentials(clientID, key, strategy).resolve(ctx, opts) + if err == nil { + r.Close = func() error { released++; return nil } + } + return r, err + }) + c, err := NewClient(t.Context(), WithCredentials(creds), withZeroKMSURL(url)) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + t.Cleanup(func() { + _ = c.Close() + _ = c.Close() + if released != 1 { + t.Errorf("Close released the credentials %d times, want once", released) + } + // The client never closes the caller's strategy. + if _, err := strategy.Token(context.Background()); err != nil { + t.Errorf("the strategy after Client.Close: %v, want still usable", err) + } + }) + if released != 0 { + t.Fatalf("a successful NewClient released the credentials %d times, want 0", released) + } + // The successful outcome of the consumption contract, which only a + // real load-keyset response can reach: the key is empty and the bytes + // it was built from are zero once the client exists. + if !key.IsZero() { + t.Error("the key still holds material after NewClient succeeded") + } + for i, b := range material { + if b != 0 { + t.Fatalf("byte %d of the key material was not wiped by a successful NewClient", i) + } + } + return c +} + +type liveUser struct { + ID int64 `stash:"-"` + Age uint32 `stash:"context=users/age,index=eq;ore"` + Email string `stash:"context=users/email,index=eq;match"` +} + +func TestLiveValueRoundTrip(t *testing.T) { + c := liveClient(t) + ctx := t.Context() + cipher := c.DefaultKeyset() + aad := []byte("users/v1") + in := map[string]any{"name": "alice", "age": uint32(34), "note": vcvalue.Plain{V: "clear"}} + + c.transport.sends.Store(0) + ct, err := cipher.Encrypt(ctx, in, aad) + if err != nil { + t.Fatal(err) + } + if n := c.transport.sends.Load(); n != 1 { + t.Errorf("encrypt made %d ZeroKMS calls, want 1", n) + } + fields := ct.(map[string]any) + if _, ok := fields["name"].(Sealed); !ok { + t.Fatalf("name sealed as %T", fields["name"]) + } + if fields["note"] != (vcvalue.Plain{V: "clear"}) { + t.Fatalf("passthrough came back as %v", fields["note"]) + } + + for name, open := range map[string]func() (any, error){ + "bound": func() (any, error) { return cipher.Decrypt(ctx, ct, aad) }, + "client": func() (any, error) { return c.Decrypt(ctx, ct, aad) }, + } { + pt, err := open() + if err != nil { + t.Fatalf("%s decrypt: %v", name, err) + } + want := vcvalue.Object{{Key: "age", Value: uint32(34)}, {Key: "name", Value: "alice"}, {Key: "note", Value: vcvalue.Plain{V: "clear"}}} + if !reflect.DeepEqual(pt, want) { + t.Fatalf("%s decrypt = %#v", name, pt) + } + } + if _, err := cipher.Decrypt(ctx, ct, []byte("wrong")); err == nil { + t.Fatal("wrong AAD decrypted") + } + // The default keyset's id is what the leaves carry: the bound cipher of + // that id opens them too. + defID, err := cipher.KeysetID(ctx) + if err != nil { + t.Fatalf("resolve the default keyset: %v", err) + } + if _, err := c.Keyset(defID).Decrypt(ctx, ct, aad); err != nil { + t.Fatalf("decrypt under the default keyset by id: %v", err) + } +} + +func TestLiveRecordsAndTerms(t *testing.T) { + c := liveClient(t) + ctx := t.Context() + cipher := c.DefaultKeyset() + users := []liveUser{{1, 34, "alice@example.com"}, {2, 29, "bob@example.com"}} + + c.transport.sends.Store(0) + records, err := cipher.EncryptRecords(ctx, users) + if err != nil { + t.Fatal(err) + } + if n := c.transport.sends.Load(); n != 1 { + t.Errorf("EncryptRecords made %d ZeroKMS calls for %d rows, want 1", n, len(users)) + } + if len(records) != 2 || len(records[0]["Age"].Equality) != 32 || records[0]["Email"].Match == nil || records[0]["Age"].Ore == nil { + t.Fatalf("records = %+v", records) + } + + probe, err := cipher.Term(ctx, uint32(34), MustContext("users/age"), Equality) + if err != nil { + t.Fatal(err) + } + if !probe.(EqualityTerm).Equal(records[0]["Age"].Equality) { + t.Error("probe does not equal the stored equality term") + } + if probe.(EqualityTerm).Equal(records[1]["Age"].Equality) { + t.Error("probe equals another value's term") + } + + var back []liveUser + if err := cipher.DecryptRecords(ctx, records, &back); err != nil { + t.Fatal(err) + } + for i := range users { + users[i].ID = 0 // not part of the record + } + if !reflect.DeepEqual(back, users) { + t.Fatalf("decrypted %+v, want %+v", back, users) + } + var one liveUser + if err := c.DecryptRecord(ctx, records[1], &one); err != nil || one.Email != "bob@example.com" { + t.Fatalf("DecryptRecord: %v %+v", err, one) + } + + // A context extension is part of the identity. + ext, err := cipher.EncryptRecords(ctx, users, ExtendContext(uint64(7))) + if err != nil { + t.Fatal(err) + } + if err := cipher.DecryptRecords(ctx, ext, &back); !errors.Is(err, ErrForbidden) && !errors.Is(err, ErrAuthentication) { + t.Fatalf("extended record opened without its extension: %v", err) + } + if err := cipher.DecryptRecords(ctx, ext, &back, ExtendContext(uint64(7))); err != nil { + t.Fatalf("extended record with its extension: %v", err) + } +} + +// An explicit plan round-trips a struct that carries no tags, and a record +// is only readable under the plan it was written under. +func TestLiveExplicitPlanRoundTrip(t *testing.T) { + c := liveClient(t) + ctx := t.Context() + cipher := c.DefaultKeyset() + type generated struct { // no tags, as protobuf output has none + Age uint32 + Email string + } + plan, err := NewPlan( + FieldPlan{Field: "Age", Context: "users/age", Terms: []TermKind{Equality, Ore}}, + FieldPlan{Field: "Email", Context: "users/email", Terms: []TermKind{Equality, Match}}, + ) + if err != nil { + t.Fatal(err) + } + users := []generated{{34, "alice@example.com"}, {29, "bob@example.com"}} + + records, err := cipher.EncryptRecords(ctx, users, WithPlan(plan)) + if err != nil { + t.Fatal(err) + } + if len(records) != 2 || len(records[0]["Age"].Equality) != 32 || records[0]["Email"].Match == nil { + t.Fatalf("records = %+v", records) + } + var back []generated + if err := cipher.DecryptRecords(ctx, records, &back, WithPlan(plan)); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(back, users) { + t.Fatalf("decrypted %+v, want %+v", back, users) + } + var one generated + if err := c.DecryptRecord(ctx, records[1], &one, WithPlan(plan)); err != nil || one.Email != "bob@example.com" { + t.Fatalf("DecryptRecord: %v %+v", err, one) + } + + // A plan naming a field the record does not carry is refused before + // any key is requested. + other, err := NewPlan(FieldPlan{Field: "Email", Name: "email", Context: "users/email"}) + if err != nil { + t.Fatal(err) + } + c.transport.sends.Store(0) + err = cipher.DecryptRecords(ctx, records, &back, WithPlan(other)) + if err == nil || !strings.Contains(err.Error(), `no ciphertext for field "email"`) { + t.Fatalf("mismatched plan: %v", err) + } + if n := c.transport.sends.Load(); n != 0 { + t.Errorf("mismatched plan made %d ZeroKMS calls, want 0", n) + } +} + +func TestLiveForeignKeysetIsRefusedBeforeRetrieval(t *testing.T) { + c := liveClient(t) + ctx := t.Context() + other := os.Getenv("STACK_ENCRYPT_TEST_OTHER_KEYSET") + if other == "" { + t.Skip("STACK_ENCRYPT_TEST_OTHER_KEYSET not set") + } + ct, err := c.Keyset(KeysetName(other)).Encrypt(ctx, "tenant b", nil) + if err != nil { + t.Fatal(err) + } + c.transport.sends.Store(0) + if _, err := c.DefaultKeyset().Decrypt(ctx, ct, nil); !errors.Is(err, ErrForeignKeyset) { + t.Fatalf("default cipher opened another keyset's leaf: %v", err) + } + if n := c.transport.sends.Load(); n != 0 { + t.Errorf("a foreign leaf cost %d ZeroKMS calls before refusal", n) + } + if pt, err := c.Decrypt(ctx, ct, nil); err != nil || pt != "tenant b" { + t.Fatalf("client decrypt of the other keyset: %v %v", pt, err) + } +} + +// Per-call hygiene on a real round trip: once Encrypt has returned, the +// plaintext it was given is nowhere in guest memory — the staged input was +// wiped by se_dealloc — so between calls the guest holds only the client +// key and its keyset cache. +func TestPlaintextDoesNotRemainInGuestMemoryAfterEncrypt(t *testing.T) { + c := liveClient(t) + ctx := t.Context() + const plaintext = "residency-probe-4111-b1c2d3e4f5" + if _, err := c.DefaultKeyset().Encrypt(ctx, plaintext, nil); err != nil { + t.Fatalf("Encrypt: %v", err) + } + mem := c.inst.module.Memory() + view, ok := mem.Read(0, mem.Size()) + if !ok { + t.Fatal("cannot read guest memory") + } + if n := bytes.Count(view, []byte(plaintext)); n != 0 { + t.Fatalf("plaintext found %d times in guest memory after Encrypt returned", n) + } +} + +// The zero-configuration path end to end: NewClient with no options, its +// credentials from AutoCredentials, the token from a real access-key +// exchange — the CI shape of a deployment, with the variables the Rust +// client reads and no developer profile. +func TestLiveAutoCredentialsFromTheEnvironment(t *testing.T) { + clientID, clientKey := os.Getenv("STACK_ENCRYPT_TEST_CLIENT_ID"), os.Getenv("STACK_ENCRYPT_TEST_CLIENT_KEY") + accessKey, crn := os.Getenv("STACK_ENCRYPT_TEST_CLIENT_ACCESS_KEY"), os.Getenv("STACK_ENCRYPT_TEST_WORKSPACE_CRN") + if clientID == "" || clientKey == "" || accessKey == "" || crn == "" { + t.Skip("STACK_ENCRYPT_TEST_{CLIENT_ID,CLIENT_KEY,CLIENT_ACCESS_KEY,WORKSPACE_CRN} not set") + } + guestOrSkip(t) + authGuestOrSkip(t) + // An empty profile directory, so only the environment can answer, and + // none of the developer's own CS_* variables. + cleanEnv(t, t.TempDir()) + if cts := os.Getenv("STACK_ENCRYPT_TEST_CTS_HOST"); cts != "" { + t.Setenv("CS_CTS_HOST", cts) + } else if err := os.Unsetenv("CS_CTS_HOST"); err != nil { // cleanEnv's placeholder + t.Fatal(err) + } + if url := os.Getenv("STACK_ENCRYPT_TEST_ZEROKMS_URL"); url != "" { + t.Setenv("CS_ZEROKMS_HOST", url) + } + t.Setenv(envAccessKey, accessKey) + t.Setenv(envWorkspaceCRN, crn) + t.Setenv(envClientID, clientID) + t.Setenv(envClientKey, clientKey) + + ctx := t.Context() + c, err := NewClient(ctx) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer func() { + if err := c.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }() + // Whether memory locks depends on the host; that it is reported, and + // consistently, does not. + if err := c.MemoryLockError(); err != nil && !errors.Is(err, ErrMemoryLock) { + t.Errorf("MemoryLockError = %v, want nil or ErrMemoryLock", err) + } + if c.MemoryLocked() != (c.MemoryLockError() == nil) { + t.Error("MemoryLocked disagrees with MemoryLockError") + } + + aad := []byte("users/v1") + ct, err := c.DefaultKeyset().Encrypt(ctx, "alice", aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if _, ok := ct.(Sealed); !ok { + t.Fatalf("sealed as %T", ct) + } + pt, err := c.Decrypt(ctx, ct, aad) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if pt != "alice" { + t.Fatalf("Decrypt = %#v, want %q", pt, "alice") + } +} diff --git a/languages/golang/stackencrypt/memory_linux_test.go b/languages/golang/stackencrypt/memory_linux_test.go new file mode 100644 index 000000000..2577ec0be --- /dev/null +++ b/languages/golang/stackencrypt/memory_linux_test.go @@ -0,0 +1,20 @@ +package stackencrypt + +import ( + "context" + "testing" + + "github.com/cipherstash/stack/languages/golang/internal/guesttest" +) + +// The real guest's mapping, once a host-staged buffer has made it grow: +// excluded from dumps, and locked where the host granted it, as seen from +// /proc/self/smaps. The probe's mapping is checked the same way in +// internal/guest. +func TestGuestMappingIsLockedAndNotDumpable(t *testing.T) { + c := rawInstance(t) + if err := stageLarge(context.Background(), c.inst); err != nil { + t.Fatal(err) + } + guesttest.AssertMappingProtected(t, c.inst.mem, guesttest.MemoryBase(t, c.inst.module.Memory())) +} diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go new file mode 100644 index 000000000..4df671d1e --- /dev/null +++ b/languages/golang/stackencrypt/memory_test.go @@ -0,0 +1,323 @@ +package stackencrypt + +import ( + "context" + "errors" + "fmt" + "net/http" + "runtime" + "strings" + "testing" + "time" + + "github.com/tetratelabs/wazero/api" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guesttest" + "github.com/cipherstash/stack/languages/golang/stackauth" +) + +// The allocator on its own is tested in internal/guest. These are the +// properties a Client over the real guest has because of it, and what the +// Client reports about its memory. + +// stageLarge stages a buffer larger than the guest's initial memory, so +// the guest must grow, and frees it again. +func stageLarge(ctx context.Context, inst *instance) error { + staged, err := inst.exports.AllocWrite(ctx, inst.module, make([]byte, 2<<20)) + if err != nil { + return err + } + inst.exports.Free(ctx, staged) + return nil +} + +// invoke calls one export on already-staged arguments and decodes its +// packed result, for the tests that need to refuse growth between staging +// and the call. +func invoke(ctx context.Context, fn api.Function, params ...uint64) error { + res, err := fn.Call(ctx, params...) + if err != nil { + return fmt.Errorf("%w: guest call: %w", guest.ErrTrap, err) + } + _, _, err = guest.PackedResult(res[0]) + return err +} + +// A host-staged buffer larger than the guest's initial memory makes it +// grow, and its memory stays where it was: growth commits more of one +// reservation, so the guest's keys are never copied to a new slice. The +// guest's own mapping is checked in smaps on Linux, in +// memory_linux_test.go. +func TestGuestGrowsInPlace(t *testing.T) { + c := rawInstance(t) + mem := c.inst.module.Memory() + if c.inst.mem.IsFallback() { + t.Skipf("heap fallback in use on this host: %v", c.inst.mem.LockError()) + } + before, pagesBefore := guesttest.MemoryBase(t, mem), mem.Size()/guesttest.WasmPage + if err := stageLarge(context.Background(), c.inst); err != nil { + t.Fatal(err) + } + if after := guesttest.MemoryBase(t, mem); after != before { + t.Fatalf("guest memory moved on growth: %#x -> %#x", before, after) + } + if pagesAfter := mem.Size() / guesttest.WasmPage; pagesAfter <= pagesBefore { + t.Fatalf("guest memory did not grow: %d pages before, %d after", pagesBefore, pagesAfter) + } +} + +// Strict mode is a NewClient failure, not a report. The refusal is +// provoked by lowering RLIMIT_MEMLOCK to zero. +func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { + if !guesttest.InChild(t) { + return + } + if err := guesttest.SetMemlockLimit(0); err != nil { + t.Fatalf("lowering RLIMIT_MEMLOCK: %v", err) + } + // Can the lock be refused at all here? Root and CAP_IPC_LOCK ignore + // the limit. On a 32-bit host there is no reservation to lock, and the + // strict refusal is the reservation's, not the limit's. + probe := guest.NewAllocator(guest.BestEffort) + _, _, done := guesttest.ProbeMemory(t, probe) + done() + if probe.LockError() == nil { + fmt.Println("case skipped: mlock succeeds under RLIMIT_MEMLOCK=0") + return + } + limited := !probe.IsFallback() + _, err := NewClient(context.Background(), + WithCredentials(newTestCredentials("6a70bd18-99ac-4650-b104-37eec3a15b09", NewClientKey([]byte("00")), staticToken("t"))), + WithGuest(wasiProbe), + WithRequireLockedMemory(), + ) + if !errors.Is(err, ErrMemoryLock) { + t.Fatalf("strict NewClient under a refused lock: %v, want ErrMemoryLock", err) + } + if limited && !strings.Contains(err.Error(), "RLIMIT_MEMLOCK") { + t.Fatalf("the error does not name the limit: %v", err) + } + // Best effort under the same refusal: the client exists, says so, and + // shows it wherever it is printed or logged. + if wasm, gerr := embeddedGuest(); gerr == nil { + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: staticToken("t")}, guest.BestEffort) + if err != nil { + t.Fatal(err) + } + c := newClient(inst, nil) + t.Cleanup(func() { _ = c.Close() }) + if c.MemoryLocked() { + t.Fatal("best-effort client reports locked memory under a refused lock") + } + if err := c.MemoryLockError(); !errors.Is(err, ErrMemoryLock) { + t.Fatalf("MemoryLockError = %v, want ErrMemoryLock", err) + } + if s := fmt.Sprint(c); !strings.Contains(s, "unlocked") || (limited && !strings.Contains(s, "RLIMIT_MEMLOCK")) { + t.Fatalf("Client prints as %q: no memory state", s) + } + if v := c.LogValue().String(); !strings.Contains(v, "memory_locked=false") { + t.Fatalf("Client logs as %q: no memory state", v) + } + } + fmt.Println("case ok") +} + +// WithRequireLockedMemory covers a store the caller opened best effort +// behind NewCredentials: with RLIMIT_MEMLOCK at zero the store opens +// unlocked, and NewClient refuses the credentials with ErrMemoryLock, before +// the crypto guest is instantiated. +func TestRequireLockedMemoryRefusesACallerStoreUnlocked(t *testing.T) { + if !guesttest.InChild(t) { + return + } + if err := guesttest.SetMemlockLimit(0); err != nil { + t.Fatalf("lowering RLIMIT_MEMLOCK: %v", err) + } + ctx := context.Background() + store, err := stackauth.OpenWithoutProfile(ctx) + if errors.Is(err, stackauth.ErrGuestNotBuilt) { + fmt.Println("case skipped:", err) + return + } + if err != nil { + t.Fatal(err) + } + defer store.Close() + if store.MemoryLocked() { + fmt.Println("case skipped: mlock succeeds under RLIMIT_MEMLOCK=0") + return + } + strategy, err := store.AccessKey(ctx, "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY", "CSAKtestKeyId.testKeySecret", stackauth.WithAuthBaseURL("https://cts.invalid")) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + if err := strategy.MemoryLockError(); !errors.Is(err, ErrMemoryLock) { + t.Fatalf("Strategy.MemoryLockError = %v, want the store's ErrMemoryLock", err) + } + key := NewClientKey([]byte("00")) + _, err = NewClient(ctx, + WithCredentials(NewCredentials("6a70bd18-99ac-4650-b104-37eec3a15b09", key, strategy)), + WithGuest(wasiProbe), + withZeroKMSURL("https://zerokms.invalid"), + WithRequireLockedMemory(), + ) + if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "credentials' memory") { + t.Fatalf("strict NewClient over an unlocked caller store: %v, want ErrMemoryLock naming the credentials", err) + } + if !key.IsZero() { + t.Fatal("the key still holds material after the credentials were refused") + } + fmt.Println("case ok") +} + +// strictClient is a Client over the real guest under the strict policy, +// or a skip where this host refuses the lock. +func strictClient(t *testing.T) *Client { + t.Helper() + if !guesttest.HostReserves(t) { + t.Skip("heap fallback in use on this host: a strict client cannot exist") + } + inst, err := newInstance(context.Background(), guestOrSkip(t), &transport{rt: http.DefaultTransport, token: staticToken("t")}, guest.Strict) + if errors.Is(err, ErrMemoryLock) { + guesttest.SkipUnlessLockRequired(t, "the lock was refused", err) + } + if err != nil { + t.Fatal(err) + } + c := newClient(inst, nil) + t.Cleanup(func() { _ = c.Close() }) + if !c.MemoryLocked() { + t.Fatalf("a strict client reports unlocked memory: %v", c.MemoryLockError()) + } + return c +} + +// Through the Client: the call that needed the growth for a host-staged +// buffer fails with ErrMemoryLock naming the refusal, the client is still +// open, and it still reports locked memory everywhere it is asked — the +// method, the error, the print and the log. +func TestRequireLockedMemoryFailsTheCallThatCannotGrow(t *testing.T) { + ctx := context.Background() + c := strictClient(t) + refusing := guest.RefuseGrowth(c.inst.mem, errors.New("refused for the test")) + stage := func(inst *instance) ([]byte, error) { return nil, stageLarge(ctx, inst) } + _, err := c.call(ctx, stage) + if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "growth refused") || !strings.Contains(err.Error(), refusing.Reason().Error()) { + t.Fatalf("call needing a refused growth: %v; want ErrMemoryLock naming the refusal", err) + } + if refusing.Refused() == 0 { + t.Fatal("the guest did not grow; the test proves nothing") + } + if !c.MemoryLocked() { + t.Fatalf("a refused growth unlocked the report: %v", c.MemoryLockError()) + } + if err := c.MemoryLockError(); err != nil { + t.Fatalf("MemoryLockError = %v after a refused growth, want nil", err) + } + if s := fmt.Sprint(c); !strings.HasSuffix(s, "memory: locked}") { + t.Fatalf("Client prints as %q after a refused growth", s) + } + if v := c.LogValue().String(); !strings.Contains(v, "memory_locked=true") { + t.Fatalf("Client logs as %q after a refused growth", v) + } + // The client is still open, and grows once it can. + refusing.Allow() + if _, err := c.call(ctx, stage); err != nil { + t.Fatalf("the next call, growth allowed: %v", err) + } + if !c.MemoryLocked() { + t.Fatalf("the report changed on a granted growth: %v", c.MemoryLockError()) + } +} + +// A growth the guest needs for an allocation of its own is refused the +// same way, but the guest cannot report it: its allocator aborts, the trap +// closes the module, and the client is closed with it, its keys wiped. +// The call still fails with ErrMemoryLock naming the refusal, and the +// client is ErrState from then on. Provoked by staging a config the guest +// must copy while decoding, with the refusal installed after the staging. +func TestRequireLockedMemoryClosesTheClientOnARefusedInternalGrowth(t *testing.T) { + ctx := context.Background() + c := strictClient(t) + cfg := initConfig{clientID: strings.Repeat("a", 2<<20), clientKey: NewClientKey([]byte("00"))} + encoded, err := encodeConfig(cfg) + if err != nil { + t.Fatal(err) + } + var refusing *guest.Refusing + _, err = c.call(ctx, func(inst *instance) ([]byte, error) { + staged, err := inst.exports.AllocWrite(ctx, inst.module, encoded) + if err != nil { + return nil, err + } + defer inst.exports.Free(ctx, staged) + refusing = guest.RefuseGrowth(inst.mem, errors.New("refused for the test")) + return nil, invoke(ctx, inst.cipherInit, uint64(staged.Ptr), uint64(staged.Len)) + }) + if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "growth refused") { + t.Fatalf("init needing a refused internal growth: %v; want ErrMemoryLock naming the refusal", err) + } + if refusing.Refused() == 0 { + t.Fatal("the guest did not grow; the test proves nothing") + } + if !c.inst.module.IsClosed() || !strings.Contains(err.Error(), "the client is closed") { + t.Fatalf("the guest's abort did not close the client: %v", err) + } + if !c.inst.mem.IsFreed() { + t.Fatal("the closed client's memory was not wiped and freed") + } + if _, err := c.call(ctx, func(*instance) ([]byte, error) { return nil, nil }); !errors.Is(err, ErrState) { + t.Fatalf("a call after the abort: %v, want ErrState", err) + } + if !c.MemoryLocked() { + t.Fatalf("a refused growth unlocked the report: %v", c.MemoryLockError()) + } + if err := c.Close(); err != nil { + t.Fatalf("Close after the abort: %v", err) + } +} + +// A Client that becomes unreachable without Close is released by its +// cleanup: the guest's shutdown runs and the memory is wiped and freed. It +// covers the forgot-to-close case in a running process, and nothing at +// exit. +func TestUnreachableClientIsReleased(t *testing.T) { + wasm := guestOrSkip(t) + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: staticToken("t")}, guest.BestEffort) + if err != nil { + t.Fatal(err) + } + alloc := inst.mem + func() { + c := newClient(inst, nil) + if c.inst.mem.IsFreed() { + t.Fatal("freed on construction") + } + }() + inst = nil + deadline := time.Now().Add(10 * time.Second) + for !alloc.IsFreed() { + if time.Now().After(deadline) { + t.Fatal("an unreachable client's memory was not released") + } + runtime.GC() + time.Sleep(10 * time.Millisecond) + } +} + +// Close stops the cleanup, so a closed client is released exactly once. +func TestCloseStopsTheCleanup(t *testing.T) { + c := rawInstance(t) + if err := c.Close(); err != nil { + t.Fatal(err) + } + if !c.inst.mem.IsFreed() { + t.Fatal("Close did not free the guest memory") + } + // Stop on a cleanup Close already stopped is a no-op, so a second Stop + // here proves nothing on its own; what is pinned is that the release + // ran once, through Close, and the memory is gone. + c.cleanup.Stop() +} diff --git a/languages/golang/stackencrypt/options.go b/languages/golang/stackencrypt/options.go new file mode 100644 index 000000000..d35b0eef4 --- /dev/null +++ b/languages/golang/stackencrypt/options.go @@ -0,0 +1,97 @@ +package stackencrypt + +import "net/http" + +// ClientOption configures [NewClient]. Each one sets one thing; a later +// option setting the same thing wins. NewClient with none is a working +// client, with its credentials from [AutoCredentials]. +type ClientOption func(*clientOptions) + +// clientOptions is what the options set. Every zero value is the default. +type clientOptions struct { + credentials Credentials + // superseded is every earlier WithCredentials a later one replaced. + // NewClient consumes them too: a key handed to WithCredentials is + // wiped whichever option wins. + superseded []Credentials + // zerokmsURL is set only by the tests' withZeroKMSURL, to reach a + // stub. Applications get the endpoint from the token's services claim, + // or CS_ZEROKMS_HOST, as the Rust client does. + zerokmsURL string + keysetCacheSize int + transport http.RoundTripper + guest []byte + requireLockedMemory bool +} + +// WithCredentials supplies the client id, the client key and the stackauth +// strategy the token comes from. The default, and what nil means, is +// [AutoCredentials]: the environment, then the developer profile. +// [NewCredentials] takes the three explicitly, and [OIDCFederation] mints tokens from an identity provider's. +// +// The client key is consumed: NewClient marshals it into the config buffer, +// wipes the key, and wipes the buffer once the guest has the key, so after +// NewClient returns — whatever the outcome, a configuration it refused +// included — the key is empty and the bytes it was built from are zero. A +// key is for one client. That holds for credentials a later WithCredentials +// replaces as well: they are consumed, not left holding a live key. +func WithCredentials(c Credentials) ClientOption { + return func(o *clientOptions) { + if o.credentials != nil { + o.superseded = append(o.superseded, o.credentials) + } + o.credentials = c + } +} + +// WithKeysetCacheSize sets how many keysets beyond the default the guest +// keeps loaded. Zero means the crate default (1024); negative is refused. +func WithKeysetCacheSize(n int) ClientOption { + return func(o *clientOptions) { o.keysetCacheSize = n } +} + +// WithTransport performs the client's HTTP requests: to ZeroKMS, and, for +// [AutoCredentials] and [OIDCFederation], the authentication requests +// stackauth's credential guest makes to CTS: an access-key exchange, a +// device-session refresh, a federation exchange. A RoundTripper scoped to +// the ZeroKMS host alone (a pinned client certificate, an egress allowlist) +// refuses those; the failure then surfaces as the token strategy's. Under +// [NewCredentials] the token exchange runs in the store the caller opened, +// not through this RoundTripper: pass stackauth.WithRoundTripper to that +// store instead. Nil means http.DefaultTransport, the default. +func WithTransport(rt http.RoundTripper) ClientOption { + return func(o *clientOptions) { o.transport = rt } +} + +// WithGuest overrides the embedded wasm module. Nil means the embedded one, +// the default. +func WithGuest(wasm []byte) ClientOption { + return func(o *clientOptions) { o.guest = wasm } +} + +// WithRequireLockedMemory makes NewClient fail with ErrMemoryLock when the +// guest's memory cannot be locked in RAM or, on Linux, excluded from core +// dumps, instead of continuing with memory that may be swapped or dumped +// and reporting so through Client.MemoryLocked. It holds for the life of +// the client: a later growth of the guest's memory that cannot be locked is +// refused too, and what the guest already holds stays locked. When the +// growth was for a buffer the host is staging, the call fails with +// ErrMemoryLock and the client goes on. When it was for the guest's own +// allocation, the guest cannot report it: it aborts, and the client is +// closed with its keys wiped, the call still failing with ErrMemoryLock. +// Set it where swap is a real exposure and the deployment grants a lock +// limit with room for the guest to grow (RLIMIT_MEMLOCK on Linux; the error +// names the size held so far); see [Client.MemoryLocked]. +// +// It covers the credential guest too, where the token strategy lives and +// the client key may have passed through. [AutoCredentials] and +// [OIDCFederation] open that guest under the same policy, so it is refused +// at NewClient and on every later growth alike. [NewCredentials]' guest is +// the stackauth store the caller opened: NewClient fails with +// ErrMemoryLock if that store's memory is unlocked when it is asked, but +// only the store's own policy governs its later growth, so open it with +// stackauth.RequireLockedMemory to hold it locked for the life of the +// client. Client.MemoryLocked reports the store's state live either way. +func WithRequireLockedMemory() ClientOption { + return func(o *clientOptions) { o.requireLockedMemory = true } +} diff --git a/languages/golang/stackencrypt/options_test.go b/languages/golang/stackencrypt/options_test.go new file mode 100644 index 000000000..a5a730a0f --- /dev/null +++ b/languages/golang/stackencrypt/options_test.go @@ -0,0 +1,232 @@ +package stackencrypt + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "sync" + "sync/atomic" + "testing" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/stackauth" +) + +// Every option sets its one field, and a later option setting the same +// field wins. The zero value of each is the default NewClient applies. +func TestClientOptionsSetTheirField(t *testing.T) { + creds := AutoCredentials() + rt := roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("unused") }) + wasm := []byte("\x00asm") + var got clientOptions + for _, opt := range []ClientOption{ + withZeroKMSURL("https://first.example"), + WithCredentials(creds), + withZeroKMSURL("https://second.example"), + WithKeysetCacheSize(4096), + WithTransport(rt), + WithGuest(wasm), + WithRequireLockedMemory(), + } { + opt(&got) + } + if got.credentials != creds || got.zerokmsURL != "https://second.example" || got.keysetCacheSize != 4096 || + got.transport == nil || string(got.guest) != string(wasm) || !got.requireLockedMemory { + t.Fatalf("options = %+v", got) + } +} + +// A WithCredentials a later one replaces is never resolved, but its key is +// still consumed: wiped, and its credentials refused if reused. That holds +// whether the replacement is other credentials or nil, the default. +func TestLaterCredentialsConsumeTheOnesTheyReplace(t *testing.T) { + guestOrSkip(t) + for name, later := range map[string]func(*testing.T) Credentials{ + "other credentials": func(*testing.T) Credentials { return testCredentials(staticToken("stub-token")) }, + "nil": func(t *testing.T) Credentials { + cleanEnv(t, filepath.Join(t.TempDir(), "absent")) + return nil + }, + } { + t.Run(name, func(t *testing.T) { + stub := newStub(t, http.StatusUnauthorized, "", "nope") + key := NewClientKey([]byte(testClientKey)) + replaced := newTestCredentials(testClientID, key, staticToken("stub-token")) + _, _ = NewClient(context.Background(), + WithCredentials(replaced), + WithCredentials(later(t)), + withZeroKMSURL(stub.URL), + ) + if !key.IsZero() { + t.Error("the replaced credentials' key still holds material") + } + if _, err := NewClient(context.Background(), WithCredentials(replaced), withZeroKMSURL(stub.URL)); !errors.Is(err, ErrCredentialsConsumed) { + t.Errorf("reusing the replaced credentials: %v, want ErrCredentialsConsumed", err) + } + }) + } +} + +// The same credentials passed to WithCredentials twice are the ones that +// win, not ones replaced: their key is resolved, not consumed, and the +// client reaches ZeroKMS. +func TestTheSameCredentialsPassedTwiceAreNotConsumed(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + creds := testCredentials(staticToken("stub-token")) + _, err := NewClient(context.Background(), + WithCredentials(creds), + WithCredentials(creds), + withZeroKMSURL(stub.URL), + ) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized from the stub", err) + } + if len(stub.requests) != 1 { + t.Fatalf("requests: %d, want one", len(stub.requests)) + } +} + +// Every Credentials constructor returns a comparable value: comparing two +// does not panic, whichever constructor made them. +func TestCredentialsAreComparable(t *testing.T) { + a := OIDCFederation("crn:a", nil) + b := OIDCFederation("crn:b", nil, stackauth.WithAuthBaseURL("https://cts.example.com")) + if a == b || AutoCredentials() != AutoCredentials() { + t.Fatal("unexpected comparison result") + } + _ = map[Credentials]bool{a: true, b: true, AutoCredentials(): true, testCredentials(staticToken("t")): true} +} + +// A later WithKeysetCacheSize replaces an earlier one before anything is +// checked: a negative size overridden by zero, the default, is accepted, +// and the client goes on to ZeroKMS. +func TestLaterKeysetCacheSizeWins(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + _, err := NewClient(context.Background(), + WithCredentials(testCredentials(staticToken("stub-token"))), + withZeroKMSURL(stub.URL), + WithKeysetCacheSize(-1), + WithKeysetCacheSize(0), + ) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized from the stub", err) + } + if len(stub.requests) != 1 { + t.Fatalf("requests: %d, want one: the overridden size was refused", len(stub.requests)) + } +} + +// countingTransport records the hosts a RoundTripper was asked to reach. +type countingTransport struct { + mu sync.Mutex + hosts map[string]int +} + +func (c *countingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + c.mu.Lock() + if c.hosts == nil { + c.hosts = map[string]int{} + } + c.hosts[r.URL.Host]++ + c.mu.Unlock() + return http.DefaultTransport.RoundTrip(r) +} + +func (c *countingTransport) count(rawURL string) int { + u, _ := url.Parse(rawURL) + c.mu.Lock() + defer c.mu.Unlock() + return c.hosts[u.Host] +} + +// OIDCFederation mints its token from the provider's through CTS, asking +// the provider only when a token has to be minted; its client key comes +// from the environment as AutoCredentials' does; and WithTransport carries +// the token exchange as well as the ZeroKMS request. +func TestOIDCFederationThroughTheClientTransport(t *testing.T) { + guestOrSkip(t) + authGuestOrSkip(t) + cleanEnv(t, filepath.Join(t.TempDir(), "absent")) + auth := newAuthServer(t) + t.Setenv(envClientID, testClientID) + t.Setenv(envClientKey, testClientKey) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + var idpCalls atomic.Int32 + provider := stackauth.OIDCProviderFunc(func(context.Context) (string, error) { + idpCalls.Add(1) + return "idp-token", nil + }) + rt := &countingTransport{} + _, err := NewClient(context.Background(), + WithCredentials(OIDCFederation(testCRN, provider)), + withZeroKMSURL(stub.URL), + WithTransport(rt), + ) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized from the stub", err) + } + if idpCalls.Load() != 1 || auth.calls.Load() != 1 { + t.Fatalf("provider calls %d, exchanges %d; want one of each", idpCalls.Load(), auth.calls.Load()) + } + if len(stub.requests) != 1 || stub.requests[0].auth != "Bearer "+auth.jwt { + t.Fatalf("ZeroKMS requests = %+v, want one bearing the federated token", stub.requests) + } + if rt.count(auth.URL) != 1 || rt.count(stub.URL) != 1 { + t.Fatalf("transport saw %v, want the exchange and the ZeroKMS request", rt.hosts) + } +} + +func TestOIDCFederationResolvesTheKeyLikeAuto(t *testing.T) { + authGuestOrSkip(t) + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + provider := stackauth.OIDCProviderFunc(func(context.Context) (string, error) { return "idp-token", nil }) + resolved, err := OIDCFederation(testCRN, provider).resolve(context.Background(), resolveOptions{Transport: http.DefaultTransport}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resolved.Close() }() + if resolved.ClientID != profileClientID || guest.KeyBytes(resolved.ClientKey) == nil { + t.Errorf("ClientID = %q, want the profile's", resolved.ClientID) + } + // No CRN is a configuration error from the strategy, before any + // provider or key is asked. + if _, err := OIDCFederation("", provider).resolve(context.Background(), resolveOptions{Transport: http.DefaultTransport}); !errors.Is(err, stackauth.ErrAuthConfig) { + t.Fatalf("OIDCFederation with no CRN: %v, want ErrAuthConfig", err) + } +} + +// OIDCFederation's strategy options reach the strategy: WithAuthBaseURL +// pins CTS for these credentials, over CS_CTS_HOST, which here names a +// decoy that fails the test if it is asked. +func TestOIDCFederationTakesStrategyOptions(t *testing.T) { + authGuestOrSkip(t) + cleanEnv(t, filepath.Join(t.TempDir(), "absent")) + auth := newAuthServer(t) + decoy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("CS_CTS_HOST was asked (%s) though WithAuthBaseURL pinned CTS", r.URL.Path) + http.NotFound(w, r) + })) + t.Cleanup(decoy.Close) + t.Setenv("CS_CTS_HOST", decoy.URL) + t.Setenv(envClientID, testClientID) + t.Setenv(envClientKey, testClientKey) + provider := stackauth.OIDCProviderFunc(func(context.Context) (string, error) { return "idp-token", nil }) + creds := OIDCFederation(testCRN, provider, stackauth.WithAuthBaseURL(auth.URL)) + resolved, err := creds.resolve(context.Background(), resolveOptions{Transport: http.DefaultTransport}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resolved.Close() }() + resolved.ClientKey.Wipe() + if got := token(t, resolved); got != auth.jwt { + t.Fatalf("token = %q, want the pinned CTS's", got) + } + if auth.calls.Load() != 1 { + t.Fatalf("exchanges at the pinned CTS: %d, want one", auth.calls.Load()) + } +} diff --git a/languages/golang/stackencrypt/order_live_test.go b/languages/golang/stackencrypt/order_live_test.go new file mode 100644 index 000000000..6f4e3affe --- /dev/null +++ b/languages/golang/stackencrypt/order_live_test.go @@ -0,0 +1,169 @@ +package stackencrypt + +import ( + "bytes" + "cmp" + "context" + "fmt" + "math/rand" + "reflect" + "testing" + "testing/quick" +) + +// Property tests of term ordering: random plaintexts, terms derived by the +// guest, comparison in Go. ORE is probabilistic — a wrong comparator or a +// wrong derivation still agrees with plaintext order on many pairs — so a +// fixed vector set says little; hundreds of random pairs per type say +// more. The terms come from the guest's index key, which needs a loaded +// keyset, so these run under the live harness (skipped without +// credentials) — see live_test.go. + +// orderProperty checks, for random pairs of T, that the Go comparison of +// their terms agrees with the plaintext order and that a term compares +// equal to itself (derivation is deterministic). gen, when given, replaces +// quick's generator for T. +func orderProperty[T any](t *testing.T, cipher *Cipher, kind TermKind, less func(a, b T) int, gen ...func(*rand.Rand) T) { + t.Helper() + ctx := context.Background() + context := MustContext(fmt.Sprintf("prop/%s/%T", kind, *new(T))) + term := func(v T) []byte { + t.Helper() + out, err := cipher.Term(ctx, v, context, kind) + if err != nil { + t.Fatalf("Term(%v): %v", v, err) + } + switch tt := out.(type) { + case OreTerm: + return tt + case OpeTerm: + return tt + default: + t.Fatalf("Term returned %T", out) + return nil + } + } + compare := func(a, b []byte) int { + if kind == Ore { + return OreTerm(a).Compare(OreTerm(b)) + } + return OpeTerm(a).Compare(OpeTerm(b)) + } + sign := func(n int) int { + return cmp.Compare(n, 0) + } + holds := func(a, b T) bool { + ta, tb := term(a), term(b) + if compare(ta, ta) != 0 || compare(tb, tb) != 0 { + t.Logf("a term does not compare equal to itself: %v", a) + return false + } + if !bytes.Equal(ta, term(a)) { + t.Logf("derivation is not deterministic for %v", a) + return false + } + want, got := sign(less(a, b)), sign(compare(ta, tb)) + if got != want { + t.Logf("%v vs %v: plaintext order %d, term order %d", a, b, want, got) + return false + } + return sign(compare(tb, ta)) == -want + } + cfg := &quick.Config{MaxCount: 300, Rand: rand.New(rand.NewSource(int64(kind)))} + if len(gen) > 0 { + cfg.Values = func(args []reflect.Value, r *rand.Rand) { + for i := range args { + args[i] = reflect.ValueOf(gen[0](r)) + } + } + } + if err := quick.Check(holds, cfg); err != nil { + t.Fatal(err) + } +} + +// Neighbouring values are where a comparator that mishandles the last +// differing bit shows; quick's uniform generator almost never produces +// them, so they are checked explicitly alongside. +func adjacentProperty[T any](t *testing.T, cipher *Cipher, kind TermKind, values []T, less func(a, b T) int) { + t.Helper() + ctx := context.Background() + context := MustContext(fmt.Sprintf("prop/%s/%T", kind, *new(T))) + terms := make([][]byte, len(values)) + for i, v := range values { + out, err := cipher.Term(ctx, v, context, kind) + if err != nil { + t.Fatalf("Term(%v): %v", v, err) + } + terms[i] = reflect.ValueOf(out).Bytes() + } + for i := range values { + for j := range values { + var got int + if kind == Ore { + got = OreTerm(terms[i]).Compare(OreTerm(terms[j])) + } else { + got = OpeTerm(terms[i]).Compare(OpeTerm(terms[j])) + } + if want := cmp.Compare(less(values[i], values[j]), 0); got != want { + t.Errorf("%v vs %v: plaintext order %d, term order %d", values[i], values[j], want, got) + } + } + } +} + +// collatedAlphabet holds characters that the ORE and OPE string encodings +// keep as they are. Before deriving a term, cllw-ore's orderize_string +// decomposes each character canonically and drops anything that is not +// alphanumeric, whitespace or ASCII punctuation. So two strings order by +// their UTF-8 bytes only when that collation leaves both unchanged: a +// private-use character is dropped, and a precomposed Hangul syllable or +// an accented letter decomposes. The non-ASCII letters here have no +// canonical decomposition, so multi-byte UTF-8 ordering is still covered. +var collatedAlphabet = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~ßжω中") + +func collatedString(r *rand.Rand) string { + out := make([]rune, r.Intn(24)) + for i := range out { + out[i] = collatedAlphabet[r.Intn(len(collatedAlphabet))] + } + return string(out) +} + +func TestLiveTermOrderIsPlaintextOrder(t *testing.T) { + c := liveClient(t) + cipher := c.DefaultKeyset() + for _, kind := range []TermKind{Ore, Ope} { + t.Run(kind.String(), func(t *testing.T) { + t.Run("uint32", func(t *testing.T) { + orderProperty(t, cipher, kind, cmp.Compare[uint32]) + adjacentProperty(t, cipher, kind, []uint32{0, 1, 2, 255, 256, 257, 65535, 65536, 1<<31 - 1, 1 << 31, 1<<32 - 2, 1<<32 - 1}, cmp.Compare[uint32]) + }) + t.Run("uint64", func(t *testing.T) { + orderProperty(t, cipher, kind, cmp.Compare[uint64]) + adjacentProperty(t, cipher, kind, []uint64{0, 1, 1<<32 - 1, 1 << 32, 1<<63 - 1, 1 << 63, 1<<64 - 1}, cmp.Compare[uint64]) + }) + t.Run("int64", func(t *testing.T) { + orderProperty(t, cipher, kind, cmp.Compare[int64]) + adjacentProperty(t, cipher, kind, []int64{-1 << 63, -1<<63 + 1, -2, -1, 0, 1, 2, 1<<63 - 1}, cmp.Compare[int64]) + }) + t.Run("string", func(t *testing.T) { + // Strings order by the UTF-8 bytes of their collated form; a + // prefix orders before its extensions. See collatedAlphabet. + orderProperty(t, cipher, kind, func(a, b string) int { return bytes.Compare([]byte(a), []byte(b)) }, collatedString) + adjacentProperty(t, cipher, kind, []string{"", "a", "aa", "ab", "b", "ba", "ß", "ßa", "中"}, func(a, b string) int { return bytes.Compare([]byte(a), []byte(b)) }) + // Collation drops a control or private-use character, and + // strips the accent from a decomposed letter, so the terms + // cannot tell these pairs apart. + same := func(a, b string) int { return 0 } + adjacentProperty(t, cipher, kind, []string{"", "\x7f"}, same) + adjacentProperty(t, cipher, kind, []string{"ab", "a\ue000b"}, same) + adjacentProperty(t, cipher, kind, []string{"e", "é"}, same) + }) + t.Run("bytes", func(t *testing.T) { + orderProperty(t, cipher, kind, bytes.Compare) + adjacentProperty(t, cipher, kind, [][]byte{{}, {0}, {0, 0}, {0, 1}, {1}, {255}, {255, 0}}, bytes.Compare) + }) + }) + } +} diff --git a/languages/golang/stackencrypt/plan/doc.go b/languages/golang/stackencrypt/plan/doc.go new file mode 100644 index 000000000..385da3a99 --- /dev/null +++ b/languages/golang/stackencrypt/plan/doc.go @@ -0,0 +1,81 @@ +// Package plan builds a record [stackencrypt.Plan] from what a domain +// schema already says about its fields, through a policy written in Go. +// +// Storage decisions do not belong in the schema. The schema carries facts — +// a field's name, its kind, and its annotations, such as Fideslang +// `data_categories` — and a [Policy], a pure function of a field's [Fact], +// decides what becomes of it: [Encrypt] into a [Target], [Plaintext], or +// [Fail]. Policies are ordinary values and compose: [When] is a rule, +// [FirstOf] takes the first rule that matches, and [Policy.OrElse] refines a +// shared base per message. +// +// var category = plan.Key("fides.data_categories") +// +// var Base = plan.FirstOf( +// plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(stackencrypt.Equality))), +// plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(stackencrypt.Equality, stackencrypt.Match))), +// plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), +// ) +// +// var Individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), +// plan.FirstOf( +// plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), +// plan.Column("medicare_number")), +// ).OrElse(Base), +// ) +// +// var individuals = plan.MustPlanFor(source, Individuals) +// // cipher.EncryptRecords(ctx, rows, stackencrypt.WithPlan(individuals)) +// +// # Facts +// +// A [Source] makes the facts for a message. A protobuf source (planned in +// CIP-4088) reads descriptors and their custom options, and needs nothing +// from this package beyond [Fact], [Source] and [Key]. Any function +// returning facts is one: +// +// var source = plan.SourceFunc(func(msg any) ([]plan.Fact, error) { +// return []plan.Fact{ +// {Field: "id", GoField: "ID"}, +// {Field: "email", GoField: "Email", Annotations: []plan.Annotation{ +// {Key: "fides.data_categories", Values: []string{"user.contact.email"}}}}, +// {Field: "medicare_no", GoField: "MedicareNo", Annotations: []plan.Annotation{ +// {Key: "fides.data_categories", Values: []string{"user.government_id"}}}}, +// }, nil +// }) +// +// [Message.Build] takes facts directly, so a generator or a test can build +// a plan without a source at all; [PlanFor] also checks the plan binds to +// the message's Go type. +// +// # Contexts +// +// A field's context is its AAD, its ZeroKMS data-key binding and its terms' +// PRF context, fixed when data is first written. For an [EQL] target the +// context is the column identity, "<table>/<column>" ([Identifier]): the +// table is the message's [Table], which is required and never derived from +// the message's name, and the column is the column the field is first +// stored in. A rule sets that column with [Column] (the field's schema name +// by default), which for an EQL target sets the identity too. Once data is +// written the identity must never change, so after a database rename +// (ALTER TABLE ... RENAME COLUMN) the rule stores into the new column and +// pins the old identity with [Identity]: +// +// plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), +// plan.Column("medicare_num"), plan.Identity("medicare_number")) +// +// A [Custom] target supplies its context itself; [Column] names only its +// record key, and [Identity] is refused. +// +// # Failing closed +// +// A field with annotations that no rule decides is an error when the plan +// is built ([ErrUnmatched]), naming the field and its annotations; there is +// no built-in default, so a catch-all, Plaintext included, is written in the +// policy. A field with no annotations that no rule names is not the +// policy's concern: it is left out of the plan and stored as it is. A +// message the policy encrypts nothing of has no plan to build +// ([ErrNothingEncrypted]): its records are stored without one. Build plans +// at startup with [MustPlanFor], so a gap stops the process before it +// writes anything. +package plan diff --git a/languages/golang/stackencrypt/plan/fact.go b/languages/golang/stackencrypt/plan/fact.go new file mode 100644 index 000000000..6955da6fa --- /dev/null +++ b/languages/golang/stackencrypt/plan/fact.go @@ -0,0 +1,114 @@ +package plan + +import ( + "fmt" + "slices" + "strings" +) + +// Fact is what the SDK knows about one field of a message: where it is, +// what it is, and the annotations the domain schema put on it. It is +// source-agnostic. A [Source] makes facts from something that describes a +// message, such as a protobuf descriptor; a policy reads them and never +// learns where they came from. +type Fact struct { + // Message is the message's (or struct's) full name, for errors. + Message string + // Field is the field's schema name: the proto field name, or, for a + // struct, the Go field name in snake_case. It is the column a field + // encrypts into unless a rule pins another ([Column]). + Field string + // GoField is the Go struct field the plan binds to. Field when empty. + GoField string + // Number is the proto field number; 0 when the source has none. + Number int32 + // Kind is the field's kind in the source's own spelling ("string", + // "int64", ...); informational, for rules that match on it. + Kind string + // Annotations are the facts proper: the domain schema's classification + // of the field, such as its Fideslang data categories. A field with no + // annotations is not the policy's concern unless a rule names it. + Annotations []Annotation +} + +// Annotation is one annotation on a field: a key and its values. For a +// protobuf extension the key is the extension's full name and the values +// are its (repeated) string values. +type Annotation struct { + Key string + Values []string +} + +// Values returns the values of every annotation on the field under key, +// in order. +func (f Fact) Values(key string) []string { + var out []string + for _, a := range f.Annotations { + if a.Key == key { + out = append(out, a.Values...) + } + } + return out +} + +// hasValue reports whether any value under key satisfies pred, without +// collecting the values: the matchers run once per rule per field. +func (f Fact) hasValue(key string, pred func(string) bool) bool { + for _, a := range f.Annotations { + if a.Key != key { + continue + } + if slices.ContainsFunc(a.Values, pred) { + return true + } + } + return false +} + +// goField is the Go struct field the fact binds to. +func (f Fact) goField() string { + if f.GoField != "" { + return f.GoField + } + return f.Field +} + +// String names the field — and the Go field it binds to, when that is +// spelled differently — and its annotations, the way errors name them. +func (f Fact) String() string { + var b strings.Builder + if f.Message != "" { + b.WriteString(f.Message) + b.WriteByte('.') + } + b.WriteString(f.Field) + if f.GoField != "" && f.GoField != f.Field { + b.WriteString(" (") + b.WriteString(f.GoField) + b.WriteByte(')') + } + if len(f.Annotations) > 0 { + b.WriteString(" [") + for i, a := range f.Annotations { + if i > 0 { + b.WriteString("; ") + } + fmt.Fprintf(&b, "%s=%s", a.Key, strings.Join(a.Values, ",")) + } + b.WriteByte(']') + } + return b.String() +} + +// Source makes the facts for a message. msg is whatever [ForMessage] was +// given, such as a proto.Message for the protobuf source planned in +// CIP-4088. Facts are returned in field order. +type Source interface { + Facts(msg any) ([]Fact, error) +} + +// SourceFunc adapts a function to a [Source]. +type SourceFunc func(msg any) ([]Fact, error) + +// Facts calls f. +func (f SourceFunc) Facts(msg any) ([]Fact, error) { return f(msg) } diff --git a/languages/golang/stackencrypt/plan/message.go b/languages/golang/stackencrypt/plan/message.go new file mode 100644 index 000000000..9b827226a --- /dev/null +++ b/languages/golang/stackencrypt/plan/message.go @@ -0,0 +1,248 @@ +package plan + +import ( + "errors" + "fmt" + "reflect" + "strings" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +var ( + // ErrUnmatched is a field that carries annotations no rule of the + // policy decides. There is no built-in default: a catch-all, even + // Plaintext, must be written in the policy. + ErrUnmatched = errors.New("no rule decides the field") + // ErrRefused is a field the policy decided to [Fail]. + ErrRefused = errors.New("the policy refuses the field") + // ErrInvalid is a decision that cannot become a plan field: no target, + // a pinned column or identity on a field that is not encrypted, an + // identity on a Custom target, an empty context, a '/' in a column + // identity, or two EQL fields sharing one identity. + ErrInvalid = errors.New("invalid decision") + // ErrNothingEncrypted is a message the policy encrypts no field of: + // every classified field decided Plaintext, or none classified. Such a + // message has no plan to build, and its records are stored without + // one — a [stackencrypt.Plan] always seals at least one field. + ErrNothingEncrypted = errors.New("the policy encrypts no field of the message") +) + +// Table names the table a message's records are stored in: the first half +// of every EQL field's column identity. It is required, and never derived +// from the message's name, because it is part of every context and a +// guess (a pluralisation) would be permanent. +type Table string + +// Message is a policy for one message type: the message, its table, and +// the rules its fields are decided by. A Message is a value; build it once +// (a package-level var) and build its plan at startup with [MustPlanFor]. +type Message struct { + msg any + table Table + policy Policy +} + +// ForMessage scopes policy to msg, stored in table. msg is what the +// [Source] reads facts from, such as a proto.Message for a protobuf +// source. Refine a shared base per message with OrElse: +// +// var Individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), +// plan.FirstOf( +// plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), +// plan.Column("medicare_number")), +// ).OrElse(Base), +// ) +func ForMessage(msg any, table Table, policy Policy) Message { + return Message{msg: msg, table: table, policy: policy} +} + +// Msg returns the message the policy is for. +func (m Message) Msg() any { return m.msg } + +// Table returns the message's table. +func (m Message) Table() Table { return m.table } + +// Decide runs the message's policy on one field. +func (m Message) Decide(f Fact) (Decision, bool) { return m.policy.Decide(f) } + +// Build decides every field of facts and returns the plan: one field per +// Encrypt decision, in fact order. A field no rule matches is plaintext +// when it has no annotations (not the policy's concern) and ErrUnmatched +// when it has any; a Fail decision is ErrRefused. Every failing field is +// reported, not only the first, each naming the field and its facts. +// +// Pure: no I/O, no client. Build is what [PlanFor] runs after reading the +// facts, and what a generator or a golden test runs on facts it holds. +func (m Message) Build(facts []Fact) (stackencrypt.Plan, error) { + if m.table == "" { + return stackencrypt.Plan{}, fmt.Errorf("plan: %s: a message needs a Table", messageName(m, facts)) + } + if strings.Contains(string(m.table), "/") { + return stackencrypt.Plan{}, fmt.Errorf("plan: %s: table %q contains '/', which would make its column identities ambiguous", messageName(m, facts), m.table) + } + var fields []stackencrypt.FieldPlan + var errs []error + // An EQL identity is one column's context: two fields sharing one would + // bind each other's ciphertexts and terms. NewPlan refuses a shared + // record key, but with Identity the identity can be shared without one. + identities := map[string]Fact{} + for _, f := range facts { + fp, id, planned, err := m.field(f) + if err == nil && id != "" { + if prev, dup := identities[id]; dup { + err = fmt.Errorf("%w: identity %q is already field %s's", ErrInvalid, id, prev.Field) + } else { + identities[id] = f + } + } + if err != nil { + errs = append(errs, fmt.Errorf("plan: %s: %w", f, err)) + continue + } + if planned { + fields = append(fields, fp) + } + } + if len(errs) > 0 { + return stackencrypt.Plan{}, errors.Join(errs...) + } + if len(fields) == 0 { + return stackencrypt.Plan{}, fmt.Errorf("plan: %s: %w; a message with nothing to encrypt needs no plan", messageName(m, facts), ErrNothingEncrypted) + } + p, err := stackencrypt.NewPlan(fields...) + if err != nil { + return stackencrypt.Plan{}, fmt.Errorf("plan: %s: %w", messageName(m, facts), err) + } + return p, nil +} + +// field decides one field: its plan field, its EQL identity ("" for a +// Custom target), and whether it is planned. +func (m Message) field(f Fact) (stackencrypt.FieldPlan, string, bool, error) { + none := func(err error) (stackencrypt.FieldPlan, string, bool, error) { + return stackencrypt.FieldPlan{}, "", false, err + } + d, ok := m.policy.Decide(f) + if !ok { + if len(f.Annotations) == 0 { + return none(nil) + } + return none(ErrUnmatched) + } + switch d.verdict { + case encrypt: + case plaintext: + if d.column != "" { + return none(fmt.Errorf("%w: Column(%q) pinned on a Plaintext field", ErrInvalid, d.column)) + } + if d.identity != "" { + return none(fmt.Errorf("%w: Identity(%q) pinned on a Plaintext field", ErrInvalid, d.identity)) + } + return none(nil) + case fail: + return none(fmt.Errorf("%w: %s", ErrRefused, d.reason)) + default: + return none(fmt.Errorf("%w: the zero Decision", ErrInvalid)) + } + if d.target == nil { + return none(fmt.Errorf("%w: Encrypt with no target", ErrInvalid)) + } + column := f.Field + if d.column != "" { + column = d.column + } + if column == "" { + return none(fmt.Errorf("%w: the field has no name to store it under", ErrInvalid)) + } + _, custom := d.target.(customTarget) + identity := column + if custom { + // A Custom target's context is its own: there is no identity to + // pin, and a pin would read as if it took effect. + if d.identity != "" { + return none(fmt.Errorf("%w: Identity(%q) on %v, whose context is fixed", ErrInvalid, d.identity, d.target)) + } + identity = "" + } else if d.identity != "" { + identity = d.identity + } + // A '/' in an EQL column would make an identity-shaped context + // ambiguous ("a/b" under "t" reads as "a" under "t/b" would), and the + // column is the identity until the day it is renamed. A Custom + // target's column is only the record key. + if !custom { + for _, name := range []string{column, identity} { + if strings.Contains(name, "/") { + return none(fmt.Errorf("%w: column %q contains '/', which would make its identity ambiguous", ErrInvalid, name)) + } + } + } + context := d.target.Context(Identifier{Table: string(m.table), Column: identity}) + if context == "" { + return none(fmt.Errorf("%w: target %v gives an empty context", ErrInvalid, d.target)) + } + return stackencrypt.FieldPlan{ + Field: f.goField(), + Name: column, + Context: context, + Terms: d.target.Terms(), + }, identity, true, nil +} + +func messageName(m Message, facts []Fact) string { + if len(facts) > 0 && facts[0].Message != "" { + return facts[0].Message + } + return fmt.Sprintf("%T", m.msg) +} + +// PlanFor reads m's facts from src, builds its plan (see [Message.Build]) +// and, when m's message is a struct or a pointer to one, checks the plan +// binds to it: every planned field is an exported, direct field of the +// type. A fact whose GoField the type does not have — a typo in a source, +// a generated field renamed — is then an error here, not at the first +// record call. +func PlanFor(src Source, m Message) (stackencrypt.Plan, error) { + if src == nil { + return stackencrypt.Plan{}, errors.New("plan: PlanFor needs a Source") + } + facts, err := src.Facts(m.msg) + if err != nil { + return stackencrypt.Plan{}, err + } + p, err := m.Build(facts) + if err != nil { + return stackencrypt.Plan{}, err + } + if t := structType(m.msg); t != nil { + if err := p.Validate(t); err != nil { + return stackencrypt.Plan{}, fmt.Errorf("plan: %s: %w", messageName(m, facts), err) + } + } + return p, nil +} + +// structType is msg's struct type, through one pointer, or nil when msg is +// not a struct: nothing to bind a plan to. +func structType(msg any) reflect.Type { + t := reflect.TypeOf(msg) + if t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t == nil || t.Kind() != reflect.Struct { + return nil + } + return t +} + +// MustPlanFor is [PlanFor] for startup: it panics when the policy does not +// decide every classified field, or the plan does not bind to the message, +// so a policy gap stops the process before it writes anything. +func MustPlanFor(src Source, m Message) stackencrypt.Plan { + p, err := PlanFor(src, m) + if err != nil { + panic(err) + } + return p +} diff --git a/languages/golang/stackencrypt/plan/plan_test.go b/languages/golang/stackencrypt/plan/plan_test.go new file mode 100644 index 000000000..37db29402 --- /dev/null +++ b/languages/golang/stackencrypt/plan/plan_test.go @@ -0,0 +1,476 @@ +package plan_test + +import ( + "errors" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/internal/factstest" + se "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" +) + +var category = plan.Key("fides.data_categories") + +var base = plan.FirstOf( + plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(se.Equality))), + plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(se.Equality, se.Match))), + plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), +) + +type individual struct { + ID int64 + Email string `facts:"fides.data_categories=user.contact.email"` + Name string `facts:"fides.data_categories=user.name"` + MedicareNo string `facts:"fides.data_categories=user.government_id"` + Country string `facts:"fides.data_categories=system.operations"` +} + +var individuals = plan.ForMessage(&individual{}, plan.Table("individuals"), + plan.FirstOf( + plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(se.Equality, se.Ore)), plan.Column("medicare_number")), + plan.When(category.Under("system"), plan.Plaintext()), + ).OrElse(base), +) + +func TestPolicyBuildsThePlan(t *testing.T) { + p, err := plan.PlanFor(factstest.StructTags, individuals) + if err != nil { + t.Fatal(err) + } + // Columns are the schema's spelling of the Go field: what the Rust + // derive binds and the database names. + want := []se.FieldPlan{ + {Field: "Email", Name: "email", Context: "individuals/email", Terms: []se.TermKind{se.Equality, se.Match}}, + {Field: "Name", Name: "name", Context: "individuals/name"}, + // The per-message rule wins over the base's government_id rule. + {Field: "MedicareNo", Name: "medicare_number", Context: "individuals/medicare_number", Terms: []se.TermKind{se.Equality, se.Ore}}, + } + if got := p.Fields(); !reflect.DeepEqual(got, want) { + t.Fatalf("fields =\n%+v\nwant\n%+v", got, want) + } +} + +// A classified field no rule decides fails the build, naming the field and +// its facts, and every such field is reported. +func TestUnmatchedFactFailsTheBuild(t *testing.T) { + type patient struct { + ID int64 + Fingerprint []byte `facts:"fides.data_categories=user.biometric.fingerprint"` + Diagnosis string `facts:"fides.data_categories=user.health"` + Email string `facts:"fides.data_categories=user.contact.email"` + } + narrow := plan.FirstOf( + plan.When(category.Under("user.contact"), plan.Encrypt(plan.EQL(se.Equality))), + ) + m := plan.ForMessage(patient{}, "patients", narrow) + _, err := plan.PlanFor(factstest.StructTags, m) + if !errors.Is(err, plan.ErrUnmatched) { + t.Fatalf("err = %v, want ErrUnmatched", err) + } + for _, want := range []string{ + "fingerprint (Fingerprint)", "user.biometric.fingerprint", + "diagnosis (Diagnosis)", "user.health", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not name %q", err, want) + } + } + if strings.Contains(err.Error(), "Email") || strings.Contains(err.Error(), ".id") { + t.Errorf("error %q names a field that was decided or unclassified", err) + } + func() { + defer func() { + if r := recover(); r == nil { + t.Error("MustPlanFor did not panic on an unmatched fact") + } + }() + plan.MustPlanFor(factstest.StructTags, m) + }() + + // A catch-all written in the policy closes the gap; Plaintext counts. + closed := plan.ForMessage(patient{}, "patients", narrow.OrElse( + plan.When(category.Present(), plan.Plaintext()), + )) + p := plan.MustPlanFor(factstest.StructTags, closed) + if got := p.Fields(); len(got) != 1 || got[0].Field != "Email" { + t.Fatalf("fields = %+v, want Email only", got) + } +} + +// A message the policy encrypts nothing of has no plan to build: the error +// says so, rather than stackencrypt's "at least one field". +func TestNothingEncryptedIsItsOwnError(t *testing.T) { + type audit struct { + ID int64 + Kind string `facts:"fides.data_categories=system.operations"` + } + m := plan.ForMessage(audit{}, "audits", plan.When(category.Under("system"), plan.Plaintext())) + _, err := plan.PlanFor(factstest.StructTags, m) + if !errors.Is(err, plan.ErrNothingEncrypted) { + t.Fatalf("err = %v, want ErrNothingEncrypted", err) + } + if !strings.Contains(err.Error(), "audit") || !strings.Contains(err.Error(), "needs no plan") { + t.Errorf("error %q does not name the message and say what to do", err) + } +} + +// PlanFor binds the plan to the message's type: a fact naming a Go field +// the type does not have fails at build, not at the first record call. +func TestPlanForRefusesAFieldTheMessageDoesNotHave(t *testing.T) { + facts := []plan.Fact{{Message: "acme.v1.Individual", Field: "email", GoField: "EMail", + Annotations: []plan.Annotation{{Key: "k", Values: []string{"v"}}}}} + src := plan.SourceFunc(func(any) ([]plan.Fact, error) { return facts, nil }) + m := plan.ForMessage(&individual{}, "individuals", plan.When(plan.Key("k").Present(), plan.Encrypt(plan.EQL()))) + _, err := plan.PlanFor(src, m) + if err == nil || !strings.Contains(err.Error(), "EMail") || !strings.Contains(err.Error(), "not an exported field") { + t.Fatalf("err = %v, want the unbound field named", err) + } + // With no message to bind to, Build alone cannot know, and does not try. + if _, err := plan.ForMessage(nil, "individuals", plan.When(plan.Key("k").Present(), plan.Encrypt(plan.EQL()))).Build(facts); err != nil { + t.Fatalf("Build with no message: %v", err) + } +} + +// An unclassified field is not the policy's concern, but a rule may still +// name it. +func TestUnclassifiedFieldsAreLeftOutUnlessNamed(t *testing.T) { + facts := []plan.Fact{ + {Message: "acme.v1.Individual", Field: "id", GoField: "Id", Number: 1, Kind: "int64"}, + {Message: "acme.v1.Individual", Field: "notes", GoField: "Notes", Number: 2, Kind: "string"}, + } + m := plan.ForMessage(nil, "individuals", plan.When(plan.Field("notes"), plan.Encrypt(plan.EQL()))) + p, err := m.Build(facts) + if err != nil { + t.Fatal(err) + } + want := []se.FieldPlan{{Field: "Notes", Name: "notes", Context: "individuals/notes"}} + if got := p.Fields(); !reflect.DeepEqual(got, want) { + t.Fatalf("fields = %+v, want %+v", got, want) + } +} + +// A context is fixed at first write. Pinning the column keeps it, and the +// record key, through a field rename (proto or Go): on a column never +// renamed in the database, Column sets the identity too. +func TestColumnPinSurvivesRenames(t *testing.T) { + gov := []plan.Annotation{{Key: "fides.data_categories", Values: []string{"user.government_id"}}} + before := []plan.Fact{{Message: "acme.v1.Individual", Field: "medicare_number", GoField: "MedicareNumber", Annotations: gov}} + after := []plan.Fact{{Message: "acme.v1.Individual", Field: "medicare_no", GoField: "MedicareNo", Annotations: gov}} + + v1 := plan.ForMessage(nil, "individuals", base) + v2 := plan.ForMessage(nil, "individuals", plan.FirstOf( + plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(se.Equality)), plan.Column("medicare_number")), + ).OrElse(base)) + + p1, err := v1.Build(before) + if err != nil { + t.Fatal(err) + } + p2, err := v2.Build(after) + if err != nil { + t.Fatal(err) + } + f1, f2 := p1.Fields()[0], p2.Fields()[0] + if f1.Context != "individuals/medicare_number" || f2.Context != f1.Context { + t.Fatalf("contexts %q, %q: want both individuals/medicare_number", f1.Context, f2.Context) + } + if f2.Name != "medicare_number" || f2.Field != "MedicareNo" { + t.Fatalf("pinned field = %+v", f2) + } + // Without the pin, the rename would have changed the context. + unpinned, err := v1.Build(after) + if err != nil { + t.Fatal(err) + } + if got := unpinned.Fields()[0].Context; got != "individuals/medicare_no" { + t.Fatalf("unpinned context = %q", got) + } +} + +// A database rename moves the record key, never the identity: new writes go +// to the new column, under the context existing rows were written with. +func TestIdentityKeepsTheContextThroughAColumnRename(t *testing.T) { + gov := []plan.Annotation{{Key: "fides.data_categories", Values: []string{"user.government_id"}}} + facts := []plan.Fact{{Message: "acme.v1.Individual", Field: "medicare_no", GoField: "MedicareNo", Annotations: gov}} + eq := plan.Encrypt(plan.EQL(se.Equality)) + for name, tc := range map[string]struct { + opts []plan.RuleOption + key, context string + }{ + // Neither: both are the field's schema name. + "defaults": {nil, "medicare_no", "individuals/medicare_no"}, + // Column alone, on a field never renamed in the database: both. + "column": {[]plan.RuleOption{plan.Column("medicare_number")}, "medicare_number", "individuals/medicare_number"}, + // ALTER TABLE individuals RENAME COLUMN medicare_number TO medicare_num. + "renamed column": {[]plan.RuleOption{plan.Column("medicare_num"), plan.Identity("medicare_number")}, "medicare_num", "individuals/medicare_number"}, + // The column renamed to the field's own name. + "identity alone": {[]plan.RuleOption{plan.Identity("medicare_number")}, "medicare_no", "individuals/medicare_number"}, + } { + p, err := plan.ForMessage(nil, "individuals", plan.When(plan.Field("medicare_no"), eq, tc.opts...)).Build(facts) + if err != nil { + t.Errorf("%s: %v", name, err) + continue + } + want := []se.FieldPlan{{Field: "MedicareNo", Name: tc.key, Context: tc.context, Terms: []se.TermKind{se.Equality}}} + if got := p.Fields(); !reflect.DeepEqual(got, want) { + t.Errorf("%s: fields = %+v, want %+v", name, got, want) + } + } +} + +func TestContextsByTarget(t *testing.T) { + facts := []plan.Fact{ + {Field: "email", GoField: "Email", Annotations: []plan.Annotation{{Key: "k", Values: []string{"eql"}}}}, + {Field: "blob", GoField: "Blob", Annotations: []plan.Annotation{{Key: "k", Values: []string{"custom"}}}}, + } + k := plan.Key("k") + m := plan.ForMessage(nil, "users", plan.FirstOf( + plan.When(k.Is("eql"), plan.Encrypt(plan.EQL(se.Equality))), + plan.When(k.Is("custom"), plan.Encrypt(plan.Custom("tenant-blobs/v1", se.Ope)), plan.Column("blob_v1")), + )) + p, err := m.Build(facts) + if err != nil { + t.Fatal(err) + } + want := []se.FieldPlan{ + {Field: "Email", Name: "email", Context: "users/email", Terms: []se.TermKind{se.Equality}}, + // A custom target's context is its own; the pin names the record key only. + {Field: "Blob", Name: "blob_v1", Context: "tenant-blobs/v1", Terms: []se.TermKind{se.Ope}}, + } + if got := p.Fields(); !reflect.DeepEqual(got, want) { + t.Fatalf("fields =\n%+v\nwant\n%+v", got, want) + } + // A '/' in a custom target's column is only a '/' in a record key: no + // identity to make ambiguous. + slashed := plan.ForMessage(nil, "users", plan.When(k.Is("custom"), plan.Encrypt(plan.Custom("tenant-blobs/v1")), plan.Column("blob/v1"))) + p, err = slashed.Build(facts[1:]) + if err != nil { + t.Fatal(err) + } + if got := p.Fields()[0].Name; got != "blob/v1" { + t.Errorf("custom record key = %q, want blob/v1", got) + } +} + +func TestBuildRefusesMalformedDecisions(t *testing.T) { + classified := []plan.Annotation{{Key: "k", Values: []string{"v"}}} + fact := []plan.Fact{{Field: "a", Annotations: classified}} + // Each case fails for its own reason: a sentinel, or the message the + // reason is spelled by where stackencrypt reports it. + for name, tc := range map[string]struct { + table plan.Table + policy plan.Policy + want error + msg string + }{ + "no table": {"", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL())), nil, "needs a Table"}, + "slash in table": {"a/b", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL())), nil, "contains '/'"}, + "slash in column": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL()), plan.Column("x/y")), plan.ErrInvalid, "contains '/'"}, + "empty field name": {"t", plan.When(plan.Kind(""), plan.Encrypt(plan.EQL())), plan.ErrInvalid, "no name"}, + "fail": {"t", plan.When(plan.Field("a"), plan.Fail("biometrics are never stored")), plan.ErrRefused, ""}, + "nil target": {"t", plan.When(plan.Field("a"), plan.Encrypt(nil)), plan.ErrInvalid, "no target"}, + "column on plain": {"t", plan.When(plan.Field("a"), plan.Plaintext(), plan.Column("c")), plan.ErrInvalid, "Plaintext"}, + "identity on plain": {"t", plan.When(plan.Field("a"), plan.Plaintext(), plan.Identity("c")), plan.ErrInvalid, "Plaintext"}, + "identity on custom": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.Custom("ctx")), plan.Identity("c")), plan.ErrInvalid, "context is fixed"}, + "slash in identity": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL()), plan.Column("c"), plan.Identity("x/y")), plan.ErrInvalid, "contains '/'"}, + "slash, renamed": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL()), plan.Column("x/y"), plan.Identity("c")), plan.ErrInvalid, "contains '/'"}, + "zero decision": {"t", plan.When(plan.Field("a"), plan.Decision{}), plan.ErrInvalid, "zero Decision"}, + "empty context": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.Custom(""))), plan.ErrInvalid, "empty context"}, + "nil policy": {"t", nil, plan.ErrUnmatched, ""}, + "nothing encrypted": {"t", plan.When(plan.Field("a"), plan.Plaintext()), plan.ErrNothingEncrypted, "needs no plan"}, + "term kind unknown": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL(se.TermKind(9)))), nil, "unknown term kind"}, + "nil policies skip": {"t", plan.FirstOf(nil, nil), plan.ErrUnmatched, ""}, + "fail names reason": {"t", plan.When(plan.Field("a"), plan.Fail("no biometrics")), plan.ErrRefused, "no biometrics"}, + "matcher composites": {"t", plan.When(plan.All(plan.Field("a"), plan.Not(plan.Kind("string"))), plan.Fail("x")), plan.ErrRefused, ""}, + } { + facts := fact + if name == "empty field name" { + facts = []plan.Fact{{Field: "", Annotations: classified}} + } + _, err := plan.ForMessage(nil, tc.table, tc.policy).Build(facts) + if err == nil { + t.Errorf("%s: built", name) + continue + } + if tc.want != nil && !errors.Is(err, tc.want) { + t.Errorf("%s: err = %v, want %v", name, err, tc.want) + } + if tc.msg != "" && !strings.Contains(err.Error(), tc.msg) { + t.Errorf("%s: err = %q, want it to say %q", name, err, tc.msg) + } + } + _, err := plan.ForMessage(nil, "t", plan.When(plan.Field("a"), plan.Fail("no biometrics"))).Build(fact) + if !strings.Contains(err.Error(), "no biometrics") { + t.Errorf("fail error %q does not carry its reason", err) + } + // Two fields pinned to one column are one record name twice. + two := []plan.Fact{{Field: "a", Annotations: classified}, {Field: "b", Annotations: classified}} + if _, err := plan.ForMessage(nil, "t", plan.When(plan.Any(plan.Field("a"), plan.Field("b")), plan.Encrypt(plan.EQL()), plan.Column("c"))).Build(two); err == nil { + t.Error("two fields pinned to one column built") + } + // Two columns with one identity would bind each other's ciphertexts. + _, err = plan.ForMessage(nil, "t", plan.FirstOf( + plan.When(plan.Field("a"), plan.Encrypt(plan.EQL())), + plan.When(plan.Field("b"), plan.Encrypt(plan.EQL()), plan.Identity("a")), + )).Build(two) + if !errors.Is(err, plan.ErrInvalid) || !strings.Contains(err.Error(), `identity "a" is already field a's`) { + t.Errorf("two fields sharing an identity: err = %v", err) + } + // Custom targets may share a context: it is the policy's to choose. + if _, err := plan.ForMessage(nil, "t", plan.When(plan.Any(plan.Field("a"), plan.Field("b")), plan.Encrypt(plan.Custom("ctx")))).Build(two); err != nil { + t.Errorf("two custom fields sharing a context: %v", err) + } +} + +func TestKeyMatchers(t *testing.T) { + f := plan.Fact{Annotations: []plan.Annotation{ + {Key: "fides.data_categories", Values: []string{"user.contactless", "user.contact.email"}}, + {Key: "other", Values: []string{"x"}}, + }} + for m, want := range map[string]bool{ + "user": true, + "user.contact": true, + "user.contact.email": true, + "user.contact.email.work": false, + "user.contac": false, + "system": false, + } { + if got := category.Under(m)(f); got != want { + t.Errorf("Under(%q) = %v, want %v", m, got, want) + } + } + if !category.Is("user.contactless")(f) || category.Is("user")(f) { + t.Error("Is matches other than exactly") + } + if !plan.Key("other").Present()(f) || plan.Key("absent").Present()(f) { + t.Error("Present") + } + if got := f.Values("other"); !reflect.DeepEqual(got, []string{"x"}) { + t.Errorf("Values = %v", got) + } + // A prefix that could read as a catch-all but match nothing is refused + // when the rule is written, not silently dead. + for _, prefix := range []string{"", "user.", ".user", "a..b"} { + func() { + defer func() { + if recover() == nil { + t.Errorf("Under(%q) did not panic", prefix) + } + }() + category.Under(prefix) + }() + } +} + +// The combinators refuse a nil matcher when the rule is written, as When +// does, naming the combinator; and they hold their own copy of the +// matchers, so a later write to the caller's slice changes nothing. +func TestCombinatorsRefuseNilAndCopyTheirMatchers(t *testing.T) { + var unset plan.Matcher + for name, build := range map[string]func(){ + "Any": func() { plan.Any(plan.Field("a"), unset) }, + "All": func() { plan.All(unset) }, + "Not": func() { plan.Not(unset) }, + // With no matchers, All would match every field and Any none: a + // rule built from an empty slice would silently decide everything + // or nothing. + "Any()": func() { plan.Any() }, + "All()": func() { plan.All([]plan.Matcher{}...) }, + } { + func() { + defer func() { + r := recover() + if r == nil { + t.Errorf("%s did not panic", name) + } else if !strings.Contains(fmt.Sprint(r), "plan."+strings.TrimSuffix(name, "()")) { + t.Errorf("%s panicked with %v, which does not name it", name, r) + } + }() + build() + }() + } + ms := []plan.Matcher{plan.Field("a")} + any, all := plan.Any(ms...), plan.All(ms...) + ms[0] = plan.Field("b") + a, b := plan.Fact{Field: "a"}, plan.Fact{Field: "b"} + if !any(a) || any(b) || !all(a) || all(b) { + t.Error("a write to the caller's slice changed the matcher") + } +} + +func TestPinsRefuseAnEmptyName(t *testing.T) { + for name, pin := range map[string]func(string) plan.RuleOption{"Column": plan.Column, "Identity": plan.Identity} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s(\"\") did not panic", name) + } + }() + pin("") + }() + } +} + +func TestDecisionsSpellThemselves(t *testing.T) { + d, ok := plan.When(plan.Field("a"), plan.Encrypt(plan.EQL(se.Equality, se.Match)), plan.Column("c"), plan.Identity("old_c")).Decide(plan.Fact{Field: "a"}) + if !ok { + t.Fatal("no match") + } + if got, want := d.String(), `Encrypt(EQL(eq, match)) Column("c") Identity("old_c")`; got != want { + t.Errorf("String = %s, want %s", got, want) + } + if target, ok := d.Target(); !ok || target == nil || d.Column() != "c" || d.Identity() != "old_c" { + t.Errorf("accessors: %v %v %q %q", target, ok, d.Column(), d.Identity()) + } + if d := plan.Encrypt(plan.EQL()); d.Column() != "" || d.Identity() != "" { + t.Errorf("unpinned accessors: %q %q", d.Column(), d.Identity()) + } + for d, want := range map[string]string{ + plan.Plaintext().String(): "Plaintext()", + plan.Fail("no").String(): `Fail("no")`, + plan.Encrypt(plan.Custom("ctx", se.Ope)).String(): `Encrypt(Custom("ctx", ope))`, + plan.Encrypt(plan.Custom("ctx")).String(): `Encrypt(Custom("ctx"))`, + plan.Decision{}.String(): "Decision{}", + } { + if d != want { + t.Errorf("String = %s, want %s", d, want) + } + } + if _, ok := plan.Plaintext().Target(); ok { + t.Error("Plaintext has a target") + } +} + +func TestFactAndSource(t *testing.T) { + f := plan.Fact{Message: "row", Field: "email", GoField: "Email", Annotations: []plan.Annotation{ + {Key: "a", Values: []string{"x", "y"}}, {Key: "b", Values: []string{"z"}}, {Key: "a", Values: []string{"w"}}, + }} + if got := f.String(); got != "row.email (Email) [a=x,y; b=z; a=w]" { + t.Errorf("String = %s", got) + } + if got := f.Values("a"); !reflect.DeepEqual(got, []string{"x", "y", "w"}) { + t.Errorf("Values = %v", got) + } + src := plan.SourceFunc(func(any) ([]plan.Fact, error) { return []plan.Fact{f}, nil }) + if got, err := src.Facts(nil); err != nil || len(got) != 1 { + t.Errorf("SourceFunc.Facts = %v, %v", got, err) + } + if _, err := plan.PlanFor(nil, individuals); err == nil { + t.Error("PlanFor without a source") + } + if individuals.Table() != "individuals" || individuals.Msg() == nil { + t.Error("Message accessors") + } +} + +func TestWhenRefusesANilMatcher(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("When(nil, ...) did not panic") + } + }() + plan.When(nil, plan.Plaintext()) +} diff --git a/languages/golang/stackencrypt/plan/policy.go b/languages/golang/stackencrypt/plan/policy.go new file mode 100644 index 000000000..5a802d508 --- /dev/null +++ b/languages/golang/stackencrypt/plan/policy.go @@ -0,0 +1,337 @@ +package plan + +import ( + "fmt" + "slices" + "strings" + + "github.com/cipherstash/stack/languages/golang/stackencrypt" +) + +// Identifier is a field's column identity: the table its message is +// stored in and the column its data was first written to. For an EQL +// target it is the field's encryption context, so it is fixed at first +// write and must never change — which is why the table is given, never +// derived from a message name, and why a rule can pin the column half +// ([Identity]) apart from the column the value is stored in ([Column]) +// once the database column is renamed. +type Identifier struct { + Table string + Column string +} + +// String is the context an EQL target binds: "<table>/<column>", the +// shape the Rust derive gives a `#[stash(struct = T, context = "<table>")]` +// field. +func (id Identifier) String() string { return id.Table + "/" + id.Column } + +// Target is what an encrypted field is stored as: the index terms derived +// beside its ciphertext, and the context it binds. The context is the +// AAD, the ZeroKMS data-key binding and the terms' PRF context at once. +type Target interface { + // Terms lists the index terms to derive, in order. + Terms() []stackencrypt.TermKind + // Context returns the field's context given its column identity. An + // EQL target returns id.String(); a custom target returns its own. + Context(id Identifier) string +} + +// EQL is an EQL column target: the field binds its column identity +// ([Identifier.String]) as its context and derives the given terms. Typed +// EQL targets (a text-with-equality column, say) are this with the terms +// filled in, and implement [Target] the same way. +func EQL(terms ...stackencrypt.TermKind) Target { + return eqlTarget{terms: slices.Clone(terms)} +} + +type eqlTarget struct{ terms []stackencrypt.TermKind } + +func (t eqlTarget) Terms() []stackencrypt.TermKind { return slices.Clone(t.terms) } +func (t eqlTarget) Context(id Identifier) string { return id.String() } +func (t eqlTarget) String() string { return "EQL(" + termList(t.terms) + ")" } + +// Custom is a non-EQL target: the field binds context, whatever its +// column, and derives the given terms. The context need not be +// table/column shaped; it is the policy's to choose and, like any context, +// must never change once data is written under it. +func Custom(context string, terms ...stackencrypt.TermKind) Target { + return customTarget{context: context, terms: slices.Clone(terms)} +} + +type customTarget struct { + context string + terms []stackencrypt.TermKind +} + +func (t customTarget) Terms() []stackencrypt.TermKind { return slices.Clone(t.terms) } +func (t customTarget) Context(Identifier) string { return t.context } +func (t customTarget) String() string { + return fmt.Sprintf("Custom(%q%s)", t.context, prefixed(termList(t.terms))) +} + +func termList(terms []stackencrypt.TermKind) string { + names := make([]string, len(terms)) + for i, k := range terms { + names[i] = k.String() + } + return strings.Join(names, ", ") +} + +func prefixed(s string) string { + if s == "" { + return "" + } + return ", " + s +} + +type verdict uint8 + +const ( + encrypt verdict = iota + 1 + plaintext + fail +) + +// Decision is what a policy decides for one field: encrypt it into a +// target, leave it plaintext, or refuse it. Build one with [Encrypt], +// [Plaintext] or [Fail]. +type Decision struct { + verdict verdict + target Target + reason string + column string + identity string +} + +// Encrypt decides that the field is encrypted into target. +func Encrypt(target Target) Decision { return Decision{verdict: encrypt, target: target} } + +// Plaintext decides that the field is stored as it is: not part of the +// plan, never sent to the guest. +func Plaintext() Decision { return Decision{verdict: plaintext} } + +// Fail decides that the field must not be planned at all: building the +// plan fails, naming the field and reason. +func Fail(reason string) Decision { return Decision{verdict: fail, reason: reason} } + +// Target returns the decision's target, and whether it encrypts at all. +func (d Decision) Target() (Target, bool) { return d.target, d.verdict == encrypt } + +// Column returns the column the decision stores the field in, or "" for +// the field's own name. +func (d Decision) Column() string { return d.column } + +// Identity returns the column half of the identity the decision pins, or +// "" for the effective column's (see [Identity]). +func (d Decision) Identity() string { return d.identity } + +// String spells the decision for tests and errors. +func (d Decision) String() string { + var s string + switch d.verdict { + case encrypt: + s = fmt.Sprintf("Encrypt(%v)", d.target) + case plaintext: + s = "Plaintext()" + case fail: + s = fmt.Sprintf("Fail(%q)", d.reason) + default: + return "Decision{}" + } + if d.column != "" { + s += fmt.Sprintf(" Column(%q)", d.column) + } + if d.identity != "" { + s += fmt.Sprintf(" Identity(%q)", d.identity) + } + return s +} + +// Policy maps a field's facts to a decision, or reports no match (false). +// It is a pure function — no I/O, no client — so a policy is tested by +// calling it on hand-built facts. Policies compose with [FirstOf] and +// [Policy.OrElse]; [When] is the leaf. +type Policy func(Fact) (Decision, bool) + +// Decide runs the policy on one field. A nil policy matches nothing. +func (p Policy) Decide(f Fact) (Decision, bool) { + if p == nil { + return Decision{}, false + } + return p(f) +} + +// OrElse is p, falling back to q for the fields p does not match: the +// per-message refinement over a base policy. +func (p Policy) OrElse(q Policy) Policy { return FirstOf(p, q) } + +// FirstOf is the first of policies that matches a field, in order. Nil +// policies are skipped. +func FirstOf(policies ...Policy) Policy { + policies = slices.Clone(policies) + return func(f Fact) (Decision, bool) { + for _, p := range policies { + if d, ok := p.Decide(f); ok { + return d, true + } + } + return Decision{}, false + } +} + +// RuleOption adjusts the decision a [When] rule makes. +type RuleOption func(*Decision) + +// Column names the column an encrypted field is stored in: its record +// key ([stackencrypt.FieldPlan.Name]). It defaults to the field's schema +// name, so a rule needs it only when the two differ — after the field is +// renamed in the schema, say. For an EQL target, the column also sets the +// field's identity unless [Identity] pins another: on a field never +// renamed in the database, Column alone is enough. Only meaningful with +// [Encrypt]; building a plan refuses it elsewhere. An empty name is a +// programming error and panics: a pin that is not there would silently +// store the field under its own name instead. +func Column(name string) RuleOption { + if name == "" { + panic("plan.Column: empty column name") + } + return func(d *Decision) { d.column = name } +} + +// Identity pins the column half of an EQL field's identity +// ([Identifier]), and so its context "<table>/<column>": the AAD bound to +// every stored ciphertext, its ZeroKMS data-key binding and its terms' PRF +// context. It defaults to the effective [Column], so a field whose +// database column has never been renamed needs no Identity. +// +// Once data is written, a field's identity must never change: rows +// written under the old one would no longer decrypt, and their terms would +// no longer match queries. A database rename (ALTER TABLE ... RENAME +// COLUMN) is therefore spelled as the new column and the old identity: +// +// plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), +// plan.Column("medicare_no"), plan.Identity("medicare_number")) +// +// Only meaningful with an EQL [Encrypt]: a [Custom] target's context is +// its own, and building a plan refuses Identity there and on [Plaintext]. +// An empty name is a programming error and panics, as for [Column]. +func Identity(name string) RuleOption { + if name == "" { + panic("plan.Identity: empty column name") + } + return func(d *Decision) { d.identity = name } +} + +// When decides d for the fields m matches, and matches nothing else. +func When(m Matcher, d Decision, opts ...RuleOption) Policy { + if m == nil { + panic("plan.When: nil matcher") + } + for _, opt := range opts { + opt(&d) + } + return func(f Fact) (Decision, bool) { + if !m(f) { + return Decision{}, false + } + return d, true + } +} + +// Matcher is a predicate over a field's facts. +type Matcher func(Fact) bool + +// Field matches the field whose schema name ([Fact.Field]) is name. +func Field(name string) Matcher { + return func(f Fact) bool { return f.Field == name } +} + +// Kind matches fields of the given kind, in the source's spelling. +func Kind(kind string) Matcher { + return func(f Fact) bool { return f.Kind == kind } +} + +// Any matches when at least one of ms does. A nil matcher, or none at +// all, is a programming error and panics here, as it does in [When]. +func Any(ms ...Matcher) Matcher { + ms = matchers("plan.Any", ms) + return func(f Fact) bool { + return slices.ContainsFunc(ms, func(m Matcher) bool { return m(f) }) + } +} + +// All matches when every one of ms does. A nil matcher, or none at all, +// is a programming error and panics here, as it does in [When]: with no +// matchers All would match every field, so a rule built from a slice that +// came back empty would decide every field below it. A policy that needs +// a catch-all writes a final rule whose matcher says so. +func All(ms ...Matcher) Matcher { + ms = matchers("plan.All", ms) + return func(f Fact) bool { + for _, m := range ms { + if !m(f) { + return false + } + } + return true + } +} + +// Not matches when m does not. A nil matcher is a programming error and +// panics here, as it does in [When]. +func Not(m Matcher) Matcher { + if m == nil { + panic("plan.Not: nil matcher") + } + return func(f Fact) bool { return !m(f) } +} + +// matchers is a combinator's own copy of its matchers, none of them nil: +// a later write to the caller's slice must not change the matcher, and a +// nil found now names the combinator instead of crashing a build. +func matchers(combinator string, ms []Matcher) []Matcher { + if len(ms) == 0 { + panic(combinator + ": no matchers; All() would match every field and Any() none") + } + for i, m := range ms { + if m == nil { + panic(fmt.Sprintf("%s: nil matcher at index %d", combinator, i)) + } + } + return slices.Clone(ms) +} + +// Key is an annotation key, the handle rules match annotations through. A +// protobuf fact source hands out a Key per extension; for struct tags it +// is the tag's key: +// +// var category = plan.Key("fides.data_categories") +type Key string + +// Present matches fields with any value under the key. +func (k Key) Present() Matcher { + return func(f Fact) bool { return f.hasValue(string(k), func(string) bool { return true }) } +} + +// Is matches fields with value among their values under the key. +func (k Key) Is(value string) Matcher { + return func(f Fact) bool { return f.hasValue(string(k), func(v string) bool { return v == value }) } +} + +// Under matches fields with a value under the key at or below prefix in a +// dot-separated taxonomy (Fideslang's): "user.contact" matches +// "user.contact" and "user.contact.email", not "user.contactless". The +// prefix is one or more non-empty dot-separated segments; anything else +// ("", "user.", ".user", "a..b") could match nothing while reading as a +// catch-all, so it is a programming error and panics. +func (k Key) Under(prefix string) Matcher { + if prefix == "" || slices.Contains(strings.Split(prefix, "."), "") { + panic(fmt.Sprintf("plan.Key(%q).Under(%q): a prefix is one or more non-empty dot-separated segments", string(k), prefix)) + } + below := prefix + "." + return func(f Fact) bool { + return f.hasValue(string(k), func(v string) bool { + return v == prefix || strings.HasPrefix(v, below) + }) + } +} diff --git a/languages/golang/stackencrypt/policy_plan_test.go b/languages/golang/stackencrypt/policy_plan_test.go new file mode 100644 index 000000000..081ab64c1 --- /dev/null +++ b/languages/golang/stackencrypt/policy_plan_test.go @@ -0,0 +1,88 @@ +package stackencrypt_test + +import ( + "bytes" + "reflect" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/internal/factstest" + se "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" +) + +// Validate refuses a nil type as PlanFromTags does, for the zero plan +// and a built one alike, rather than dereferencing it. +func TestValidateRefusesANilType(t *testing.T) { + built, err := se.NewPlan(se.FieldPlan{Field: "A", Context: "t/a"}) + if err != nil { + t.Fatal(err) + } + for name, p := range map[string]se.Plan{"zero": {}, "built": built} { + if err := p.Validate(nil); err == nil || !strings.Contains(err.Error(), "nil type") { + t.Errorf("%s plan: Validate(nil) = %v, want an error naming the nil type", name, err) + } + } +} + +// A plan a policy builds is a Plan like any other: the guest receives +// byte-identical input to the equivalent plan built by hand. This test is +// here, not in package plan, because the guest encoding is unexported; +// plan imports stackencrypt, so only an external test can hold both. +func TestPolicyPlanIsTheHandBuiltPlan(t *testing.T) { + type individual struct { + ID int64 + Email string `facts:"fides.data_categories=user.contact.email"` + Name string `facts:"fides.data_categories=user.name"` + MedicareNo string `facts:"fides.data_categories=user.government_id"` + Country string `facts:"fides.data_categories=system.operations"` + } + category := plan.Key("fides.data_categories") + base := plan.FirstOf( + plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(se.Equality))), + plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(se.Equality, se.Match))), + plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), + plan.When(category.Present(), plan.Plaintext()), + ) + email := se.FieldPlan{Field: "Email", Name: "email", Context: "individuals/email", Terms: []se.TermKind{se.Equality, se.Match}} + name := se.FieldPlan{Field: "Name", Name: "name", Context: "individuals/name"} + for label, tc := range map[string]struct { + pins []plan.RuleOption + medicare se.FieldPlan + }{ + // The schema spelling of each column, as a Rust derive or a + // database would have it; Column alone sets the identity too. + "column": { + []plan.RuleOption{plan.Column("medicare_number")}, + se.FieldPlan{Field: "MedicareNo", Name: "medicare_number", Context: "individuals/medicare_number", Terms: []se.TermKind{se.Equality, se.Ore}}, + }, + // After a database rename: the new column, the old identity. + "renamed column": { + []plan.RuleOption{plan.Column("medicare_num"), plan.Identity("medicare_number")}, + se.FieldPlan{Field: "MedicareNo", Name: "medicare_num", Context: "individuals/medicare_number", Terms: []se.TermKind{se.Equality, se.Ore}}, + }, + } { + individuals := plan.ForMessage(&individual{}, "individuals", plan.FirstOf( + plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(se.Equality, se.Ore)), tc.pins...), + ).OrElse(base)) + fromPolicy := plan.MustPlanFor(factstest.StructTags, individuals) + byHand, err := se.NewPlan(email, name, tc.medicare) + if err != nil { + t.Fatal(err) + } + typ := reflect.TypeOf(individual{}) + for _, ext := range [][]any{nil, {uint64(7)}} { + a, err := se.GuestPlanInput(fromPolicy, typ, ext...) + if err != nil { + t.Fatal(err) + } + b, err := se.GuestPlanInput(byHand, typ, ext...) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(a, b) { + t.Fatalf("%s, extension %v: guest input differs:\npolicy %x\nhand %x", label, ext, a, b) + } + } + } +} diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go new file mode 100644 index 000000000..e29196535 --- /dev/null +++ b/languages/golang/stackencrypt/record.go @@ -0,0 +1,790 @@ +package stackencrypt + +import ( + "context" + "errors" + "fmt" + "reflect" + "slices" + "strings" + "sync" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// Record plans: per field, which context to bind and which outputs to +// derive. A plan is a value ([Plan]) with two sources: `stash` struct tags +// ([PlanFromTags], the default, and the Go stand-in for the Rust derive), or +// an explicit plan built with [NewPlan] and passed through [WithPlan] — for +// structs whose source cannot carry a tag, such as generated code. +// +// A struct field's `stash` tag says what to do with it: +// +// type User struct { +// ID int64 `stash:"-"` // not sent to the guest +// Age uint32 `stash:"context=users/age,index=eq;ore"` // sealed + equality and ORE terms +// Email string `stash:"context=users/email,index=eq;match"` // sealed + equality and match terms +// Notes string `stash:"context=users/notes"` // sealed only +// } +// +// Options are comma-separated: `context=<part>` (required for a planned +// field — the field's own context, a string part), `index=<kind>[;<kind>]` +// (eq, match, ore, ope), and `name=<wire name>` (the record key; the Go +// field name otherwise). A field tagged `-` or `plain`, or not tagged at +// all, is not part of the record: it never crosses the boundary, and stays +// the caller's to store. Unexported fields are ignored. +// +// The same plan, built by hand: +// +// plan, err := stackencrypt.NewPlan( +// stackencrypt.FieldPlan{ +// Field: "Age", +// Context: "users/age", +// Terms: []stackencrypt.TermKind{ +// stackencrypt.Equality, stackencrypt.Ore, +// }, +// }, +// stackencrypt.FieldPlan{ +// Field: "Email", +// Context: "users/email", +// Terms: []stackencrypt.TermKind{ +// stackencrypt.Equality, stackencrypt.Match, +// }, +// }, +// stackencrypt.FieldPlan{Field: "Notes", Context: "users/notes"}, +// ) +// records, err := cipher.EncryptRecords( +// ctx, users, stackencrypt.WithPlan(plan), +// ) +// +// Every planned field is sealed (the `"c"` output). What the guest receives +// is the same object whichever way the plan was built. The context each +// field binds is the plan's part, extended by [ExtendContext] parts exactly +// as the Rust derive extends a field's context by the caller's: +// NewContext(part).With(p1).With(p2). + +// EncryptedField is one field's outputs from EncryptRecords: the sealed +// ciphertext and whichever index terms the plan asked for (nil otherwise). +type EncryptedField struct { + // Ciphertext is the field's sealed value: a Sealed leaf for a scalar, + // or the same nested shape Cipher.Encrypt returns for a composite. + Ciphertext any + Equality EqualityTerm + Match MatchTerm + Ore OreTerm + Ope OpeTerm +} + +// EncryptedRecord is one record's planned fields, by wire name. +type EncryptedRecord map[string]EncryptedField + +// RecordOption adjusts how a record call binds its fields. +type RecordOption func(*recordOptions) + +type recordOptions struct { + extension []any + plan Plan +} + +// ExtendContext extends every field's context by parts, in order, the way +// the Rust derive extends a field's context by the caller's +// (encrypt_into_with_context): a field tagged context=users/age with +// ExtendContext(uint64(7)) binds ["users/age", 7]. The same extension must +// be given to decrypt the records. +func ExtendContext(parts ...any) RecordOption { + return func(o *recordOptions) { o.extension = append(o.extension, parts...) } +} + +// WithPlan encrypts or decrypts records under an explicit plan instead of +// the struct's `stash` tags. +// +// What decryption needs from the encrypting plan is what names and keys +// the ciphertext: each field's record Name, its Context (extended by the +// same [ExtendContext] parts), and the set of fields that carry a +// ciphertext. Field only selects which Go field the plaintext is written +// to, so it may differ between the two sides: a record encrypted from a +// generated struct may be decrypted into a domain struct under a plan +// with the same Names and Contexts. Terms are one-way outputs, derived on +// encryption and never sent to decrypt, so they need not match either. +func WithPlan(p Plan) RecordOption { + return func(o *recordOptions) { o.plan = p } +} + +// FieldPlan is one planned field of a record. +type FieldPlan struct { + // Field is the Go struct field name. It must be exported. + Field string + // Name is the record key the field's outputs are stored under: the + // column name, in EQL terms. Field when empty. + Name string + // Context is the field's own encryption context, a string part; the + // record call extends it by any ExtendContext parts. Required. + Context string + // Terms lists the terms to derive beside the ciphertext, in order. + Terms []TermKind +} + +// Plan is a record plan: which fields of a struct to seal, under which +// context, with which terms. It is an immutable value; the zero Plan +// means "the struct's tags". Build one with [NewPlan] or [PlanFromTags]. +type Plan struct { + d *planData +} + +// planData is the validated, shared body of a Plan. Every copy of the Plan +// points at the same body, so it is never mutated after NewPlan returns. +type planData struct { + fields []planField +} + +// planField is a validated FieldPlan: Name filled in, every term kind +// known and named once. +type planField struct { + field string + name string + context string + terms []TermKind +} + +// outputs spells the field's outputs the way the guest reads them: the +// ciphertext first, then each term. +func (f planField) outputs() []string { + out := make([]string, 1, 1+len(f.terms)) + out[0] = "c" + for _, k := range f.terms { + out = append(out, k.String()) + } + return out +} + +// NewPlan validates the fields and returns the plan. Every field needs a +// Field and a Context; Go field names must be unique, and so must record +// names (Name, or Field); Terms must be kinds this package defines, each +// at most once per field. A plan is built once and reused across calls, +// like the type it describes. +func NewPlan(fields ...FieldPlan) (Plan, error) { + p, err := newPlan(fields) + if err != nil { + return Plan{}, fmt.Errorf("stackencrypt: %w", err) + } + return p, nil +} + +// newPlan is the one validation both constructors go through; its errors +// name the field, and the caller adds the prefix and, for tags, the type. +func newPlan(fields []FieldPlan) (Plan, error) { + if len(fields) == 0 { + return Plan{}, errors.New("a plan needs at least one field") + } + d := &planData{fields: make([]planField, 0, len(fields))} + seenField := make(map[string]bool, len(fields)) + seenName := make(map[string]bool, len(fields)) + for _, f := range fields { + if f.Field == "" { + return Plan{}, errors.New("plan field without a Field name") + } + if seenField[f.Field] { + return Plan{}, fmt.Errorf("plan field %s: the Go field is planned twice", f.Field) + } + seenField[f.Field] = true + if f.Context == "" { + return Plan{}, fmt.Errorf("plan field %s: a planned field needs a context", f.Field) + } + pf := planField{field: f.Field, name: f.Field, context: f.Context} + if f.Name != "" { + pf.name = f.Name + } + for _, k := range f.Terms { + if !k.valid() { + return Plan{}, fmt.Errorf("plan field %s: unknown term kind %s", f.Field, k) + } + if slices.Contains(pf.terms, k) { + return Plan{}, fmt.Errorf("plan field %s: term kind %s given twice", f.Field, k) + } + pf.terms = append(pf.terms, k) + } + if seenName[pf.name] { + return Plan{}, fmt.Errorf("two plan fields share the record name %q", pf.name) + } + seenName[pf.name] = true + d.fields = append(d.fields, pf) + } + return Plan{d: d}, nil +} + +// Fields returns the plan's fields, in order, as they were given to NewPlan +// (Name filled in). A copy: mutating it does not touch the plan. +func (p Plan) Fields() []FieldPlan { + if p.d == nil { + return nil + } + out := make([]FieldPlan, len(p.d.fields)) + for i, f := range p.d.fields { + out[i] = FieldPlan{Field: f.field, Name: f.name, Context: f.context, Terms: slices.Clone(f.terms)} + } + return out +} + +var tagPlans sync.Map // reflect.Type → Plan + +// PlanFromTags parses (and caches) the plan a struct type's `stash` tags +// describe. This is the plan the record calls use when no WithPlan option +// is given. The tag grammar is checked here; what the fields mean is +// checked by the same validation NewPlan runs. +func PlanFromTags(t reflect.Type) (Plan, error) { + if t == nil { + return Plan{}, errors.New("stackencrypt: records must be structs, not a nil type") + } + if cached, ok := tagPlans.Load(t); ok { + return cached.(Plan), nil + } + if t.Kind() != reflect.Struct { + return Plan{}, fmt.Errorf("stackencrypt: records must be structs, not %s", t) + } + var fields []FieldPlan + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + tag, ok := f.Tag.Lookup("stash") + if !ok || tag == "-" || tag == "plain" { + continue + } + pf := FieldPlan{Field: f.Name, Name: f.Name} + for _, opt := range strings.Split(tag, ",") { + key, value, _ := strings.Cut(opt, "=") + switch key { + case "context": + pf.Context = value + case "name": + if value == "" { + return Plan{}, fmt.Errorf("stackencrypt: field %s.%s: name must not be empty", t, f.Name) + } + pf.Name = value + case "index": + for _, k := range strings.Split(value, ";") { + kind, ok := parseTermKind(k) + if !ok { + return Plan{}, fmt.Errorf("stackencrypt: field %s.%s: unknown term kind %q", t, f.Name, k) + } + pf.Terms = append(pf.Terms, kind) + } + default: + return Plan{}, fmt.Errorf("stackencrypt: field %s.%s: unknown stash tag option %q", t, f.Name, opt) + } + } + fields = append(fields, pf) + } + if len(fields) == 0 { + return Plan{}, fmt.Errorf("stackencrypt: %s has no fields tagged for encryption", t) + } + plan, err := newPlan(fields) + if err != nil { + return Plan{}, fmt.Errorf("stackencrypt: %s: %w", t, err) + } + tagPlans.Store(t, plan) + return plan, nil +} + +// fieldPlan is one planned field bound to a struct type: the plan's field +// resolved to its index. +type fieldPlan struct { + index int // struct field index + name string // wire name + context string // the field's own context part + outputs []string +} + +// Validate checks that the plan binds to t: every planned field is an +// exported, direct field of the struct type. The record calls make the +// same check on every call and fail with the same error; this is for a +// caller that builds a plan at startup for a type it knows, so a field the +// type does not have is reported then rather than at the first write. The +// zero Plan is the type's own tags, and PlanFromTags validates those. +func (p Plan) Validate(t reflect.Type) error { + if p.d == nil { + _, err := PlanFromTags(t) + return err + } + if t == nil { + return errors.New("stackencrypt: records must be structs, not a nil type") + } + _, err := p.bind(t) + return err +} + +// bind resolves the plan's fields against a struct type. Not cached: a +// name lookup per field is far below the cost of the call it precedes, and +// a cache keyed by plan would grow with every plan a caller ever built. +func (p Plan) bind(t reflect.Type) ([]fieldPlan, error) { + if t.Kind() != reflect.Struct { + return nil, fmt.Errorf("stackencrypt: records must be structs, not %s", t) + } + bound := make([]fieldPlan, len(p.d.fields)) + for i, f := range p.d.fields { + sf, ok := t.FieldByName(f.field) + if !ok || !sf.IsExported() || len(sf.Index) != 1 { + return nil, fmt.Errorf("stackencrypt: plan field %s is not an exported field of %s", f.field, t) + } + bound[i] = fieldPlan{index: sf.Index[0], name: f.name, context: f.context, outputs: f.outputs()} + } + return bound, nil +} + +// planFor binds the plan a record call runs under: the option's, or the +// struct's tags. +func planFor(t reflect.Type, o recordOptions) ([]fieldPlan, error) { + p := o.plan + if p.d == nil { + var err error + if p, err = PlanFromTags(t); err != nil { + return nil, err + } + } + return p.bind(t) +} + +// planValue renders the plan object for the guest, each field's context +// extended by the options. +func planValue(plan []fieldPlan, opts recordOptions) (vcvalue.Object, error) { + out := make(vcvalue.Object, 0, len(plan)) + for _, f := range plan { + ctx, err := NewContext(f.context) + if err != nil { + return nil, err + } + for _, part := range opts.extension { + if ctx, err = ctx.With(part); err != nil { + return nil, err + } + } + outputs := make([]any, len(f.outputs)) + for i, o := range f.outputs { + outputs[i] = o + } + out = append(out, vcvalue.Field{Key: f.name, Value: vcvalue.Object{ + {Key: "context", Value: ctx.value()}, + {Key: "outputs", Value: outputs}, + }}) + } + return out, nil +} + +func applyOptions(opts []RecordOption) recordOptions { + var o recordOptions + for _, opt := range opts { + opt(&o) + } + return o +} + +// EncryptRecords seals every row of a slice of structs (or a pointer to +// one) per the struct's `stash` tags, or per [WithPlan]: all rows and +// fields from batched ZeroKMS key requests (one per 500 sealed fields), +// terms derived under this keyset's index key. One EncryptedRecord per +// row, in order. +func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...RecordOption) ([]EncryptedRecord, error) { + v := reflect.Indirect(reflect.ValueOf(rows)) + if !v.IsValid() || v.Kind() != reflect.Slice { + return nil, fmt.Errorf("stackencrypt: EncryptRecords takes a slice of structs, not %T", rows) + } + o := applyOptions(opts) + plan, err := planFor(v.Type().Elem(), o) + if err != nil { + return nil, err + } + source := make([]any, v.Len()) + for i := range source { + source[i] = sourceRow(v.Index(i), plan) + } + tree, err := cph.encryptRecords(ctx, plan, source, o) + if err != nil { + return nil, err + } + items, ok := tree.([]any) + if !ok || len(items) != v.Len() { + return nil, fmt.Errorf("%w: record batch came back as %T", ErrInternal, tree) + } + out := make([]EncryptedRecord, len(items)) + for i, item := range items { + if out[i], err = encryptedRecordOf(item); err != nil { + return nil, err + } + } + return out, nil +} + +// EncryptRecord seals one struct (or a pointer to one) per its `stash` +// tags, or per [WithPlan]; see EncryptRecords. +func (cph *Cipher) EncryptRecord(ctx context.Context, row any, opts ...RecordOption) (EncryptedRecord, error) { + v := reflect.Indirect(reflect.ValueOf(row)) + if !v.IsValid() { + return nil, fmt.Errorf("stackencrypt: EncryptRecord takes a struct, not %T", row) + } + o := applyOptions(opts) + plan, err := planFor(v.Type(), o) + if err != nil { + return nil, err + } + tree, err := cph.encryptRecords(ctx, plan, sourceRow(v, plan), o) + if err != nil { + return nil, err + } + return encryptedRecordOf(tree) +} + +func sourceRow(row reflect.Value, plan []fieldPlan) vcvalue.Object { + out := make(vcvalue.Object, 0, len(plan)) + for _, f := range plan { + out = append(out, vcvalue.Field{Key: f.name, Value: row.Field(f.index).Interface()}) + } + return out +} + +func (cph *Cipher) encryptRecords(ctx context.Context, plan []fieldPlan, source any, o recordOptions) (any, error) { + planObj, err := planValue(plan, o) + if err != nil { + return nil, err + } + encodedSource, err := vcffi.Marshal(source) + if err != nil { + return nil, err + } + defer wipe(encodedSource) + encodedPlan, err := vcffi.Marshal(planObj) + if err != nil { + return nil, err + } + opts, err := vcffi.Marshal(options(cph.keyset)) + if err != nil { + return nil, err + } + out, err := cph.client.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.encryptRecord, buf(encodedSource), buf(encodedPlan), buf(opts)) + }) + if err != nil { + return nil, err + } + defer wipe(out) + return unmarshalCipherText(out) +} + +// encryptedRecordOf lifts one decoded record node into an EncryptedRecord. +func encryptedRecordOf(node any) (EncryptedRecord, error) { + fields, ok := node.(map[string]any) + if !ok { + return nil, fmt.Errorf("%w: record came back as %T", ErrInternal, node) + } + rec := make(EncryptedRecord, len(fields)) + for name, outputs := range fields { + om, ok := outputs.(map[string]any) + if !ok { + return nil, fmt.Errorf("%w: field %q came back as %T", ErrInternal, name, outputs) + } + var f EncryptedField + for key, out := range om { + if key == "c" { + f.Ciphertext = out + continue + } + term, err := termBytes(out) + if err != nil { + return nil, fmt.Errorf("%w: field %q output %q: %v", ErrInternal, name, key, err) + } + switch key { + case "eq": + f.Equality = term + case "match": + f.Match = term + case "ore": + f.Ore = term + case "ope": + f.Ope = term + default: + return nil, fmt.Errorf("%w: field %q has unknown output %q", ErrInternal, name, key) + } + } + rec[name] = f + } + return rec, nil +} + +// termBytes unwraps a term node: a passthrough carrying the term's bytes. +func termBytes(node any) ([]byte, error) { + plain, ok := node.(vcvalue.Plain) + if !ok { + return nil, fmt.Errorf("term node is %T, not a passthrough", node) + } + b, ok := plain.V.([]byte) + if !ok { + return nil, fmt.Errorf("term payload is %T, not bytes", plain.V) + } + return b, nil +} + +// DecryptRecords opens records produced by EncryptRecords under this keyset +// (a record from another keyset is ErrForeignKeyset) into out, a pointer to +// a slice of the same struct type, one element per record. Only the sealed +// outputs participate; terms are one-way. Fields the plan does not name are +// left as they are: when the slice already holds one row per record, each +// row keeps its other fields; otherwise it is replaced by a fresh slice. +// Nothing is written unless every record decodes. +func (cph *Cipher) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...RecordOption) error { + return cph.client.decryptRecords(ctx, cph.keyset, records, out, opts) +} + +// DecryptRecord opens one record into out, a pointer to a struct; see +// DecryptRecords. Fields the plan does not name keep their values, and +// nothing is written unless every planned field decodes. +func (cph *Cipher) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...RecordOption) error { + return cph.client.decryptRecord(ctx, cph.keyset, record, out, opts) +} + +func (c *Client) decryptRecords(ctx context.Context, sel KeysetSelector, records []EncryptedRecord, out any, opts []RecordOption) error { + ptr := reflect.ValueOf(out) + if ptr.Kind() != reflect.Pointer || ptr.IsNil() || ptr.Elem().Kind() != reflect.Slice { + return fmt.Errorf("stackencrypt: DecryptRecords writes into a pointer to a slice of structs, not %T", out) + } + o := applyOptions(opts) + plan, err := planFor(ptr.Elem().Type().Elem(), o) + if err != nil { + return err + } + tree := make([]any, len(records)) + for i, rec := range records { + if tree[i], err = recordTree(rec, plan); err != nil { + return err + } + } + values, err := c.decryptRecordTree(ctx, sel, plan, tree, o) + if err != nil { + return err + } + items, ok := values.([]any) + if !ok || len(items) != len(records) { + return fmt.Errorf("%w: record batch decrypted as %T", ErrInternal, values) + } + return commitRecords(ptr.Elem(), items, plan) +} + +// commitRecords writes decrypted records into a slice value, atomically: +// the rows are assembled in a scratch slice — copies of the existing rows +// when there is one per record, zero rows otherwise — and stored only once +// every record has been assigned. +func commitRecords(slice reflect.Value, items []any, plan []fieldPlan) error { + scratch := reflect.MakeSlice(slice.Type(), len(items), len(items)) + if slice.Len() == len(items) { + reflect.Copy(scratch, slice) + } + for i, item := range items { + if err := assignRecord(scratch.Index(i), item, plan); err != nil { + return err + } + } + slice.Set(scratch) + return nil +} + +// commitRecord writes one decrypted record into a struct value, atomically: +// a copy takes the planned fields and replaces the original only once +// every one of them has been assigned. +func commitRecord(target reflect.Value, item any, plan []fieldPlan) error { + scratch := reflect.New(target.Type()).Elem() + scratch.Set(target) + if err := assignRecord(scratch, item, plan); err != nil { + return err + } + target.Set(scratch) + return nil +} + +func (c *Client) decryptRecord(ctx context.Context, sel KeysetSelector, record EncryptedRecord, out any, opts []RecordOption) error { + ptr := reflect.ValueOf(out) + if ptr.Kind() != reflect.Pointer || ptr.IsNil() || ptr.Elem().Kind() != reflect.Struct { + return fmt.Errorf("stackencrypt: DecryptRecord writes into a pointer to a struct, not %T", out) + } + o := applyOptions(opts) + plan, err := planFor(ptr.Elem().Type(), o) + if err != nil { + return err + } + tree, err := recordTree(record, plan) + if err != nil { + return err + } + value, err := c.decryptRecordTree(ctx, sel, plan, tree, o) + if err != nil { + return err + } + return commitRecord(ptr.Elem(), value, plan) +} + +// recordTree renders the ciphertext tree the guest opens: per planned +// field, its "c" output. Terms are not sent. +func recordTree(rec EncryptedRecord, plan []fieldPlan) (map[string]any, error) { + tree := make(map[string]any, len(plan)) + for _, f := range plan { + field, ok := rec[f.name] + if !ok || field.Ciphertext == nil { + return nil, fmt.Errorf("stackencrypt: record has no ciphertext for field %q", f.name) + } + tree[f.name] = map[string]any{"c": field.Ciphertext} + } + return tree, nil +} + +func (c *Client) decryptRecordTree(ctx context.Context, sel KeysetSelector, plan []fieldPlan, tree any, o recordOptions) (any, error) { + planObj, err := planValue(plan, o) + if err != nil { + return nil, err + } + encodedTree, err := marshalCipherText(tree) + if err != nil { + return nil, err + } + encodedPlan, err := vcffi.Marshal(planObj) + if err != nil { + return nil, err + } + opts, err := vcffi.Marshal(options(sel)) + if err != nil { + return nil, err + } + out, err := c.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.decryptRecord, buf(encodedTree), buf(encodedPlan), buf(opts)) + }) + if err != nil { + return nil, err + } + defer wipe(out) + return vcffi.Unmarshal(out) +} + +// assignRecord writes a decrypted record (a vcvalue.Object of the plan's +// fields) into a struct value. +func assignRecord(target reflect.Value, value any, plan []fieldPlan) error { + obj, ok := value.(vcvalue.Object) + if !ok { + return fmt.Errorf("%w: record decrypted as %T", ErrInternal, value) + } + byName := make(map[string]any, len(obj)) + for _, f := range obj { + byName[f.Key] = f.Value + } + for _, f := range plan { + v, ok := byName[f.name] + if !ok { + return fmt.Errorf("%w: decrypted record lacks field %q", ErrInternal, f.name) + } + if err := assignField(target.Field(f.index), v); err != nil { + return fmt.Errorf("stackencrypt: field %q: %w", f.name, err) + } + } + return nil +} + +var errUnassignable = errors.New("cannot assign decrypted value") + +// assignField sets a struct field from a decoded value, converting within +// a numeric family when the value fits and refusing anything lossy. A +// decoded nil (a sealed none) is accepted only by a field that can hold +// one — a pointer, slice, map or interface — never as a zero scalar. +func assignField(field reflect.Value, v any) error { + if v == nil { + switch field.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Map, reflect.Interface: + field.Set(reflect.Zero(field.Type())) + return nil + default: + return fmt.Errorf("%w: nil into %s", errUnassignable, field.Type()) + } + } + if field.Kind() == reflect.Pointer { + elem := reflect.New(field.Type().Elem()) + if err := assignField(elem.Elem(), v); err != nil { + return err + } + field.Set(elem) + return nil + } + rv := reflect.ValueOf(v) + if rv.Type().AssignableTo(field.Type()) { + field.Set(rv) + return nil + } + // A defined type over the same kind (type Flag bool, type Raw []byte) + // converts without loss. + if rv.Kind() == field.Kind() && rv.Type().ConvertibleTo(field.Type()) { + switch field.Kind() { + case reflect.Bool, reflect.String, reflect.Slice: + field.Set(rv.Convert(field.Type())) + return nil + } + } + switch field.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + var n int64 + switch x := v.(type) { + case int32: + n = int64(x) + case int64: + n = x + default: + return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) + } + if field.OverflowInt(n) { + return fmt.Errorf("%w: %d overflows %s", errUnassignable, n, field.Type()) + } + field.SetInt(n) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + var n uint64 + switch x := v.(type) { + case uint32: + n = uint64(x) + case uint64: + n = x + default: + return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) + } + if field.OverflowUint(n) { + return fmt.Errorf("%w: %d overflows %s", errUnassignable, n, field.Type()) + } + field.SetUint(n) + case reflect.Float32, reflect.Float64: + var f float64 + switch x := v.(type) { + case float32: + f = float64(x) + case float64: + f = x + default: + return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) + } + if field.OverflowFloat(f) { + return fmt.Errorf("%w: %v overflows %s", errUnassignable, f, field.Type()) + } + // Narrowing must be exact: a float64 that float32 cannot represent + // would silently round. NaN is its own case, never equal to itself. + if field.Kind() == reflect.Float32 && float64(float32(f)) != f && f == f { + return fmt.Errorf("%w: %v is not representable as %s", errUnassignable, f, field.Type()) + } + field.SetFloat(f) + case reflect.String: + s, ok := v.(string) + if !ok { + return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) + } + field.SetString(s) + case reflect.Bool: + b, ok := v.(bool) + if !ok { + return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) + } + field.SetBool(b) + default: + return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) + } + return nil +} diff --git a/languages/golang/stackencrypt/runtime_test.go b/languages/golang/stackencrypt/runtime_test.go new file mode 100644 index 000000000..1abdb3905 --- /dev/null +++ b/languages/golang/stackencrypt/runtime_test.go @@ -0,0 +1,125 @@ +package stackencrypt + +import ( + "bytes" + "context" + "encoding/binary" + "math/rand" + "testing" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +// wasiProbe is a hand-assembled module that re-exports the two WASI +// imports the guest's cipher depends on for its security properties, so +// the module configuration can be tested without ZeroKMS and without +// adding a production export to the guest: +// +// (module +// (import "wasi_snapshot_preview1" "random_get" +// (func $random_get (param i32 i32) (result i32))) +// (import "wasi_snapshot_preview1" "clock_time_get" +// (func $clock_time_get (param i32 i64 i32) (result i32))) +// (memory (export "memory") 1) +// (func (export "random_get") (param i32 i32) (result i32) +// local.get 0 local.get 1 call $random_get) +// (func (export "clock_time_get") (param i32 i64 i32) (result i32) +// local.get 0 local.get 1 local.get 2 call $clock_time_get)) +var wasiProbe = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x0e, 0x02, 0x60, + 0x02, 0x7f, 0x7f, 0x01, 0x7f, 0x60, 0x03, 0x7f, 0x7e, 0x7f, 0x01, 0x7f, + 0x02, 0x4d, 0x02, 0x16, 0x77, 0x61, 0x73, 0x69, 0x5f, 0x73, 0x6e, 0x61, + 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, + 0x77, 0x31, 0x0a, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x5f, 0x67, 0x65, + 0x74, 0x00, 0x00, 0x16, 0x77, 0x61, 0x73, 0x69, 0x5f, 0x73, 0x6e, 0x61, + 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, + 0x77, 0x31, 0x0e, 0x63, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x74, 0x69, 0x6d, + 0x65, 0x5f, 0x67, 0x65, 0x74, 0x00, 0x01, 0x03, 0x03, 0x02, 0x00, 0x01, + 0x05, 0x03, 0x01, 0x00, 0x01, 0x07, 0x28, 0x03, 0x06, 0x6d, 0x65, 0x6d, + 0x6f, 0x72, 0x79, 0x02, 0x00, 0x0a, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, + 0x5f, 0x67, 0x65, 0x74, 0x00, 0x02, 0x0e, 0x63, 0x6c, 0x6f, 0x63, 0x6b, + 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x67, 0x65, 0x74, 0x00, 0x03, 0x0a, + 0x15, 0x02, 0x08, 0x00, 0x20, 0x00, 0x20, 0x01, 0x10, 0x00, 0x0b, 0x0a, + 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x10, 0x01, 0x0b, +} + +// probe instantiates wasiProbe in a fresh runtime under guestModuleConfig, +// exactly as newInstance instantiates the guest. +func probe(t *testing.T, ctx context.Context) (wazero.Runtime, func(n uint32) []byte, func() time.Duration) { + t.Helper() + rt := wazero.NewRuntime(ctx) + wasi_snapshot_preview1.MustInstantiate(ctx, rt) + mod, err := rt.InstantiateWithConfig(ctx, wasiProbe, guestModuleConfig()) + if err != nil { + t.Fatalf("instantiating probe: %v", err) + } + randomGet := mod.ExportedFunction("random_get") + clockTimeGet := mod.ExportedFunction("clock_time_get") + random := func(n uint32) []byte { + if res, err := randomGet.Call(ctx, 0, uint64(n)); err != nil || res[0] != 0 { + t.Fatalf("random_get: errno %v err %v", res, err) + } + out, ok := mod.Memory().Read(0, n) + if !ok { + t.Fatal("reading probe memory") + } + return bytes.Clone(out) + } + // clock_time_get(id=1 monotonic, precision, out_ptr) writes u64 nanos. + monotonic := func() time.Duration { + if res, err := clockTimeGet.Call(ctx, 1, 1, 64); err != nil || res[0] != 0 { + t.Fatalf("clock_time_get: errno %v err %v", res, err) + } + raw, ok := mod.Memory().Read(64, 8) + if !ok { + t.Fatal("reading probe memory") + } + return time.Duration(binary.LittleEndian.Uint64(raw)) + } + return rt, random, monotonic +} + +// TestGuestModuleConfigHostSources pins that the guest runs on the host's +// CSPRNG and clocks rather than wazero's deterministic defaults. A fixed +// seed would hand every Client the same ZeroKMS IV and AEAD nonce +// sequence; a fake clock would keep the keyset-name cache fresh forever. +func TestGuestModuleConfigHostSources(t *testing.T) { + ctx := context.Background() + a, randomA, monotonicA := probe(t, ctx) + defer a.Close(ctx) + b, randomB, _ := probe(t, ctx) + defer b.Close(ctx) + + const n = 32 + first, second := randomA(n), randomB(n) + if bytes.Equal(first, second) { + t.Fatalf("two fresh instances drew identical random bytes: %x", first) + } + // wazero's default is math/rand seeded with 42; neither instance may + // start on that stream. + fake := make([]byte, n) + if _, err := rand.New(rand.NewSource(42)).Read(fake); err != nil { + t.Fatal(err) + } + for _, got := range [][]byte{first, second} { + if bytes.Equal(got, fake) { + t.Fatalf("random_get is wazero's fixed-seed default: %x", got) + } + } + + // The fake clock advances 1ms per read regardless of elapsed time, so + // two reads around a sleep are 1ms apart on it and the whole sleep + // apart on the host's. The floor is half the sleep, not all of it: on + // Windows the sleep timer and the monotonic source are different + // clocks, and a sleep can return a fraction of a millisecond before + // the monotonic clock says the interval has passed. Half still leaves + // an order of magnitude between the two answers. + const sleep = 20 * time.Millisecond + before := monotonicA() + time.Sleep(sleep) + if elapsed := monotonicA() - before; elapsed < sleep/2 { + t.Fatalf("monotonic clock advanced %v across a %v sleep: not the host clock", elapsed, sleep) + } +} diff --git a/languages/golang/stackencrypt/term.go b/languages/golang/stackencrypt/term.go new file mode 100644 index 000000000..fbf38436e --- /dev/null +++ b/languages/golang/stackencrypt/term.go @@ -0,0 +1,198 @@ +package stackencrypt + +import ( + "bytes" + "crypto/subtle" + "database/sql/driver" + "encoding/binary" + "fmt" +) + +// TermKind selects which index term a probe or a plan field derives. The +// values are the guest's term-kind codes. +type TermKind uint32 + +const ( + // Equality is a PRF equality term: 32 bytes, compared with + // [EqualityTerm.Equal]. Defined for integers, strings and bytes. + Equality TermKind = 1 + // Match is a full-text match term: the tokenized positions of a string, + // as little-endian uint16s. Strings only. + Match TermKind = 2 + // Ore is an order-revealing (CLLW ORE) term over any scalar. + Ore TermKind = 3 + // Ope is an order-preserving (CLLW OPE) term over any scalar. + Ope TermKind = 4 +) + +// termKindNames is the one table of plan-tag spellings: TermKind.String, +// parseTermKind and TermKind.valid all read it. +var termKindNames = map[TermKind]string{ + Equality: "eq", + Match: "match", + Ore: "ore", + Ope: "ope", +} + +func (k TermKind) String() string { + if name, ok := termKindNames[k]; ok { + return name + } + return fmt.Sprintf("TermKind(%d)", uint32(k)) +} + +// valid reports whether k is a kind this package defines. +func (k TermKind) valid() bool { + _, ok := termKindNames[k] + return ok +} + +// parseTermKind maps a plan-tag spelling to its kind. +func parseTermKind(s string) (TermKind, bool) { + for k, name := range termKindNames { + if name == s { + return k, true + } + } + return 0, false +} + +// EqualityTerm is a PRF equality term. Two terms derived under the same +// keyset and context from equal values are equal bytes; nothing else about +// the value is revealed. +type EqualityTerm []byte + +// Equal compares two equality terms in constant time. +func (t EqualityTerm) Equal(other EqualityTerm) bool { + return subtle.ConstantTimeCompare(t, other) == 1 +} + +// MatchTerm is a full-text match term: the positions of the value's tokens +// in the keyset's token space, as little-endian uint16s. +type MatchTerm []byte + +// Positions decodes the term into its token positions. +func (t MatchTerm) Positions() ([]uint16, error) { + if len(t)%2 != 0 { + return nil, fmt.Errorf("stackencrypt: match term of %d bytes is not a whole number of positions", len(t)) + } + out := make([]uint16, len(t)/2) + for i := range out { + out[i] = binary.LittleEndian.Uint16(t[2*i:]) + } + return out, nil +} + +// OreTerm is an order-revealing term (CLLW ORE): the raw term bytes. Two +// terms derived under the same keyset and context order as their plaintexts +// through Compare and Less; plain byte order says nothing. +type OreTerm []byte + +// Compare orders two ORE terms as their plaintexts: -1, 0 or +1. Ports the +// CLLW comparison of the Rust crate (constant time over the term bytes): +// at the first differing byte the two sides share the PRF block, so they +// differ by exactly the plaintext bit, and the side one greater is the +// greater plaintext. Terms of different lengths (strings, byte slices) +// compare on their common prefix, then the shorter is less. +func (t OreTerm) Compare(other OreTerm) int { + return compareCLLW(t, other) +} + +// Less reports whether t's plaintext orders before other's. +func (t OreTerm) Less(other OreTerm) bool { return t.Compare(other) < 0 } + +// OpeTerm is an order-preserving term (CLLW OPE): the raw term bytes, +// ordered as the values they encode under plain byte order, so a database +// compares them with no custom operator. +type OpeTerm []byte + +// Compare orders two OPE terms as their plaintexts: bytes.Compare. +func (t OpeTerm) Compare(other OpeTerm) int { return bytes.Compare(t, other) } + +// Less reports whether t's plaintext orders before other's. +func (t OpeTerm) Less(other OpeTerm) bool { return t.Compare(other) < 0 } + +// compareCLLW is cllw-ore's compare_lex: compare_slice over the common +// prefix, then by length. For equal-length terms (integers) that is +// compare_slice alone. +func compareCLLW(a, b []byte) int { + n := min(len(a), len(b)) + if n > 0 { + if c := compareCLLWSlice(a[:n], b[:n]); c != 0 { + return c + } + } + switch { + case len(a) < len(b): + return -1 + case len(a) > len(b): + return 1 + default: + return 0 + } +} + +// compareCLLWSlice is cllw-ore's compare_slice: the first differing byte +// pair is found and judged in constant time; only the final translation +// to an ordering branches, after every secret-dependent step. +func compareCLLWSlice(a, b []byte) int { + var diffX, diffY, found int + for i := range a { + isDiff := 1 - subtle.ConstantTimeByteEq(a[i], b[i]) + record := isDiff & (1 - found) + diffX = subtle.ConstantTimeSelect(record, int(a[i]), diffX) + diffY = subtle.ConstantTimeSelect(record, int(b[i]), diffY) + found = subtle.ConstantTimeSelect(record, isDiff, found) + } + // x == y + 1 (mod 256) means a's plaintext bit was the 1 at the first + // difference. + greater := subtle.ConstantTimeByteEq(uint8(diffY+1), uint8(diffX)) + switch { + case found == 0: + return 0 + case greater == 1: + return 1 + default: + return -1 + } +} + +// Value implements driver.Valuer. +func (t EqualityTerm) Value() (driver.Value, error) { return []byte(t), nil } + +// Value implements driver.Valuer. +func (t MatchTerm) Value() (driver.Value, error) { return []byte(t), nil } + +// Value implements driver.Valuer. +func (t OreTerm) Value() (driver.Value, error) { return []byte(t), nil } + +// Value implements driver.Valuer. +func (t OpeTerm) Value() (driver.Value, error) { return []byte(t), nil } + +// Scan implements sql.Scanner. +func (t *EqualityTerm) Scan(src any) error { + b, err := scanBytes("EqualityTerm", src) + *t = b + return err +} + +// Scan implements sql.Scanner. +func (t *MatchTerm) Scan(src any) error { + b, err := scanBytes("MatchTerm", src) + *t = b + return err +} + +// Scan implements sql.Scanner. +func (t *OreTerm) Scan(src any) error { + b, err := scanBytes("OreTerm", src) + *t = b + return err +} + +// Scan implements sql.Scanner. +func (t *OpeTerm) Scan(src any) error { + b, err := scanBytes("OpeTerm", src) + *t = b + return err +} diff --git a/languages/golang/stackencrypt/testdata/cllw_order.txt b/languages/golang/stackencrypt/testdata/cllw_order.txt new file mode 100644 index 000000000..398b0b458 --- /dev/null +++ b/languages/golang/stackencrypt/testdata/cllw_order.txt @@ -0,0 +1,34 @@ +# CLLW ORE/OPE ciphertexts under Key::from([7u8; 32]), generated by cllw-ore (Rust); each group is in ascending plaintext order and Rust's Ord agrees. +# kind type plaintext hex (an empty ciphertext is written as -) +ore u32 0 a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b715086e2ec94e478 +ore u32 1 a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b715086e2ec94e479 +ore u32 2 a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b715086e2ec94e567 +ore u32 255 a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b72f884ab911d6822 +ore u32 256 a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7cdc7f4537ce6a8f5f +ore u32 1000 a3115ff9bd560742d3de45951cb3b7cbe816417022d00f3644f1e1bdee5b01d5 +ore u32 65535 a3115ff9bd560742d3de45951cb3b7cbe963cf9beac0cd058dea56d3522adeb6 +ore u32 4294967294 a4e067cda7ba78cc0cd0a19d528353c2aa746a205fba5f827c6a26c7b1630f6b +ore u32 4294967295 a4e067cda7ba78cc0cd0a19d528353c2aa746a205fba5f827c6a26c7b1630f6c +ore str "" - +ore str "a" a3123875af8bd1b7 +ore str "ab" a3123875af8bd1b773db99f456c23377 +ore str "abc" a3123875af8bd1b773db99f456c233772222eaddfffd39e9 +ore str "b" a3123875af8bd29c +ore str "ba" a3123875af8bd29c3a93d9aa8d0a3355 +ore str "z" a31238767caaad9f +ope u32 0 00a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b715086e2ec94e478 +ope u32 1 00a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b715086e2ec94e4f8 +ope u32 2 00a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b715086e2ec956467 +ope u32 255 00a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7bf278042b109ce7a1 +ope u32 256 00a3115ff9bd560742d3de45951cb3b7cbe816417022d00efbdc7f4537ce6a8f5f +ope u32 1000 00a3115ff9bd560742d3de45951cb3b7cbe816417022d08eb5c47160be6d5b01d5 +ope u32 65535 00a3115ff9bd560742d3de45951cb3b7cc68e34f1b6a404c850d69d652d1aa5e35 +ope u32 4294967294 01245fe74d2739f84b8c50211cd202d34229f3e99fdf39df01fbe9a64730e28e6b +ope u32 4294967295 01245fe74d2739f84b8c50211cd202d34229f3e99fdf39df01fbe9a64730e28eeb +ope str "" 00 +ope str "a" 00a391b775af8bd236 +ope str "ab" 00a391b775af8bd236745b18f456c2b277 +ope str "abc" 00a391b775af8bd236745b18f456c2b27722a269ddfffdb968 +ope str "b" 00a391b775af8c519c +ope str "ba" 00a391b775af8c519c3b1358aa8d0a33d4 +ope str "z" 00a391b7f5fbab2c9f diff --git a/languages/golang/stackencrypt/transport.go b/languages/golang/stackencrypt/transport.go new file mode 100644 index 000000000..e9514eb4b --- /dev/null +++ b/languages/golang/stackencrypt/transport.go @@ -0,0 +1,299 @@ +package stackencrypt + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "net/http" + "sort" + "strings" + "sync" + "sync/atomic" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +// transportModule is the name of the guest's one import module. Its two +// functions are the whole host surface the guest can reach. +const transportModule = "cipherstash_transport" + +// tokenSource supplies the bearer token the guest presents to ZeroKMS. It +// is asked on every request, so a source that rotates tokens needs no +// re-initialisation of the client. Outside this package's tests it is +// always a *stackauth.Strategy: minting and refresh stay host-side, out of +// the crypto guest, in stackauth's credential guest. The interface is +// unexported so that no caller can hand the client a raw token, which could +// not be refreshed and would bypass the strategies' refresh lock. +type tokenSource interface { + Token(ctx context.Context) (string, error) +} + +// transport implements the guest's two host imports over a RoundTripper +// and a tokenSource. One per Client; it is bound to the module at +// instantiation and reaches the guest's allocator through the module the +// call arrives on. +type transport struct { + rt http.RoundTripper + token tokenSource + // sends counts transport_send excursions, so tests can pin the batching + // contract (one ZeroKMS call per operation) instead of trusting it. + sends atomic.Int64 + // tokenErr is why the token source last failed during the call in + // flight: the guest sees only that token_get failed, so Client.call + // attaches the cause — ErrNoCredentials, a refused refresh — to the + // error it returns. Only touched under the client's lock, which every + // guest call holds. + tokenErr error +} + +// transportFailed is the return value of transport_send when the request +// could not be performed at all; the body then carries the error text. +const transportFailed int32 = -1 + +// hostFailed is the return value of token_get when no token is available. +const hostFailed int32 = 1 + +// maxResponseBytes bounds what transport_send will buffer from ZeroKMS. The +// guest issues at most one 500-key batch per request, which is well under a +// megabyte either way; the bound exists so that an endpoint the transport +// was pointed at cannot make the host allocate without limit. +const maxResponseBytes = 16 << 20 + +// instantiate registers the host module in r. +func (t *transport) instantiate(ctx context.Context, r wazero.Runtime) error { + _, err := r.NewHostModuleBuilder(transportModule). + NewFunctionBuilder().WithFunc(t.send).Export("transport_send"). + NewFunctionBuilder().WithFunc(t.tokenGet).Export("token_get"). + Instantiate(ctx) + if err != nil { + return fmt.Errorf("stackencrypt: instantiating host transport: %w", err) + } + return nil +} + +// send is transport_send: one HTTP request on the guest's behalf. Inputs +// are (ptr, len) pairs borrowed for the call; the two outputs are slot +// pairs filled with buffers obtained from the guest's se_alloc. Returns +// the HTTP status, or transportFailed with the error text as the body. +func (t *transport) send(ctx context.Context, m api.Module, + methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen uint32, + respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut uint32, +) int32 { + t.sends.Add(1) + mem := m.Memory() + status, respHeaders, respBody := t.perform(ctx, mem, + methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen) + // The response carries wrapped key material; once it is in guest memory + // the host copy is wiped. + defer wipe(respBody) + if !place(ctx, m, respHeadersPtrOut, respHeadersLenOut, respHeaders) || + !place(ctx, m, respBodyPtrOut, respBodyLenOut, respBody) { + // The guest reclaims whatever was placed and refuses an unplaced + // slot as an unregistered buffer; nothing more this side can do. + return transportFailed + } + return status +} + +func (t *transport) perform(ctx context.Context, mem api.Memory, + methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen uint32, +) (int32, []byte, []byte) { + method, ok1 := mem.Read(methodPtr, methodLen) + url, ok2 := mem.Read(urlPtr, urlLen) + headers, ok3 := mem.Read(headersPtr, headersLen) + body, ok4 := mem.Read(bodyPtr, bodyLen) + if !ok1 || !ok2 || !ok3 || !ok4 { + return transportFailed, nil, []byte("guest request buffers out of range") + } + // The request body may carry key-material contexts; it is copied + // because the guest wipes its own buffer when the call returns, and the + // RoundTripper may read it after this function has. The copy is owned + // by the request body handed to the RoundTripper and is wiped when that + // body is closed — not here: RoundTrip may go on reading, and close, + // in another goroutine after it has returned, on the error path + // included, and a wipe racing that send would put a truncated or + // zeroed request on the wire. + reqBody := newRequestBody(body) + req, err := http.NewRequestWithContext(ctx, string(method), string(url), reqBody) + if err != nil { + _ = reqBody.Close() + return transportFailed, nil, []byte(err.Error()) + } + // NewRequest only infers a length from the readers it knows; without + // one the transport would send the body chunked. + req.ContentLength = int64(len(body)) + req.Header = parseHeaders(headers) + resp, err := t.rt.RoundTrip(req) + if err != nil { + return transportFailed, nil, []byte(err.Error()) + } + defer resp.Body.Close() + // A caller's RoundTripper can return any int; the guest gets an i32. + if resp.StatusCode < 100 || resp.StatusCode > 999 { + return transportFailed, nil, fmt.Appendf(nil, "invalid HTTP status %d", resp.StatusCode) + } + if resp.ContentLength > maxResponseBytes { + return transportFailed, nil, fmt.Appendf(nil, "response of %d bytes exceeds the %d-byte limit", resp.ContentLength, maxResponseBytes) + } + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + if err != nil { + // ReadAll hands back what it managed to read alongside the error. + // Those bytes are a partial ZeroKMS response and can carry wrapped + // key material, so they are wiped rather than dropped on the floor + // for the collector — the same discipline as the over-limit branch + // below. + wipe(respBody) + return transportFailed, nil, []byte(err.Error()) + } + if len(respBody) > maxResponseBytes { + wipe(respBody) + return transportFailed, nil, fmt.Appendf(nil, "response exceeds the %d-byte limit", maxResponseBytes) + } + return int32(resp.StatusCode), encodeHeaders(resp.Header), respBody //nolint:gosec // range-checked above +} + +// requestBody is the io.ReadCloser a guest request goes out as. It owns +// the host copy of the body and wipes it on Close, the one point at which +// the RoundTripper contract says the transport is done with it: RoundTrip +// must close the body, but may do so in another goroutine after it has +// returned, so nothing this side can wipe any earlier without racing the +// send. Read and Close are serialised for the same reason. A transport +// that never closes the body leaves it to the collector, as any body it +// was handed; a Close before the send is complete fails the read rather +// than sending zeros in place of the request. +// +// A body of this type has no GetBody, so net/http cannot replay the +// request on a reused connection that turns out to be dead. Replay would +// need the plaintext to outlive Close, and the guest only ever POSTs, +// which net/http does not replay in any case. +type requestBody struct { + mu sync.Mutex + buf []byte + off int + closed bool +} + +var errRequestBodyClosed = errors.New("stackencrypt: request body read after close") + +// newRequestBody copies src, which the caller does not keep alive. +func newRequestBody(src []byte) *requestBody { + buf := make([]byte, len(src)) + copy(buf, src) + return &requestBody{buf: buf} +} + +func (b *requestBody) Read(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return 0, errRequestBodyClosed + } + if b.off >= len(b.buf) { + return 0, io.EOF + } + n := copy(p, b.buf[b.off:]) + b.off += n + return n, nil +} + +// Close wipes the body. It is idempotent and never fails. +func (b *requestBody) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + if !b.closed { + wipe(b.buf) + b.closed = true + } + return nil +} + +// tokenGet is token_get: hand the guest the current bearer token. +func (t *transport) tokenGet(ctx context.Context, m api.Module, tokenPtrOut, tokenLenOut uint32) int32 { + token, err := t.token.Token(ctx) + if err != nil { + t.tokenErr = err + return hostFailed + } + if token == "" { + t.tokenErr = errors.New("stackencrypt: the token source returned an empty token") + return hostFailed + } + // The credential's transport copy is wiped once it is in guest memory; + // the source's own string is the source's. + tok := []byte(token) + defer wipe(tok) + if !place(ctx, m, tokenPtrOut, tokenLenOut, tok) { + // The source did its part; the guest could not take the token (no + // allocator, a refused allocation, an out-of-range slot). Say so, + // or the failure reads as the source's. + t.tokenErr = errors.New("stackencrypt: the token could not be handed to the guest") + return hostFailed + } + return 0 +} + +// place allocates a guest buffer through the module's own se_alloc, writes +// data into it, and stores its (ptr, len) into the out-slots. The guest +// reclaims the buffer through its registry. Re-entering the guest through +// se_alloc during a host import is the one re-entry the ABI permits. +func place(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data []byte) bool { + alloc := m.ExportedFunction("se_alloc") + if alloc == nil { + return false + } + if uint64(len(data)) > math.MaxUint32 { + return false + } + res, err := alloc.Call(ctx, uint64(len(data))) + if err != nil { + return false + } + ptr := api.DecodeU32(res[0]) + if ptr == 0 { + return false + } + mem := m.Memory() + if len(data) > 0 && !mem.Write(ptr, data) { + return false + } + return mem.WriteUint32Le(ptrOut, ptr) && mem.WriteUint32Le(lenOut, uint32(len(data))) //nolint:gosec // bounded above +} + +// parseHeaders decodes the guest's `name: value` line format. Malformed +// lines are skipped, as the guest skips them in the other direction. +func parseHeaders(buf []byte) http.Header { + h := http.Header{} + for _, line := range strings.Split(string(buf), "\n") { + name, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + h.Add(strings.TrimSpace(name), strings.TrimSpace(value)) + } + return h +} + +// encodeHeaders renders response headers in the guest's line format, in +// a deterministic order, one line per value. +func encodeHeaders(h http.Header) []byte { + names := make([]string, 0, len(h)) + for name := range h { + names = append(names, name) + } + sort.Strings(names) + var out strings.Builder + for _, name := range names { + for _, value := range h[name] { + if out.Len() > 0 { + out.WriteByte('\n') + } + out.WriteString(name) + out.WriteString(": ") + out.WriteString(value) + } + } + return []byte(out.String()) +} diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go new file mode 100644 index 000000000..12c5536a8 --- /dev/null +++ b/languages/golang/stackencrypt/unit_test.go @@ -0,0 +1,615 @@ +package stackencrypt + +import ( + "bufio" + "bytes" + "context" + "encoding/hex" + "errors" + "io" + "net/http" + "os" + "reflect" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/internal/guest" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// Pure Go: no guest needed. + +func TestCommitRecordsPreservesRowsAndIsAtomic(t *testing.T) { + type row struct { + ID int64 `stash:"-"` + Age uint8 `stash:"context=users/age"` + Email string `stash:"context=users/email"` + } + plan, err := planFor(reflect.TypeOf(row{}), recordOptions{}) + if err != nil { + t.Fatal(err) + } + decoded := func(age any, email string) vcvalue.Object { + return vcvalue.Object{{Key: "Age", Value: age}, {Key: "Email", Value: email}} + } + + // One row per record: unplanned fields survive. + rows := []row{{ID: 1, Age: 9}, {ID: 2, Age: 9}} + if err := commitRecords(reflect.ValueOf(&rows).Elem(), []any{decoded(uint32(30), "a"), decoded(uint32(40), "b")}, plan); err != nil { + t.Fatal(err) + } + if want := []row{{1, 30, "a"}, {2, 40, "b"}}; !reflect.DeepEqual(rows, want) { + t.Fatalf("rows = %+v, want %+v", rows, want) + } + + // A failing record leaves the slice untouched. + before := append([]row(nil), rows...) + err = commitRecords(reflect.ValueOf(&rows).Elem(), []any{decoded(uint32(31), "c"), decoded(uint32(300), "d")}, plan) + if !errors.Is(err, errUnassignable) { + t.Fatalf("overflowing batch: %v", err) + } + if !reflect.DeepEqual(rows, before) { + t.Fatalf("partial write: %+v", rows) + } + + // A different length replaces the slice. + if err := commitRecords(reflect.ValueOf(&rows).Elem(), []any{decoded(uint32(1), "z")}, plan); err != nil { + t.Fatal(err) + } + if want := []row{{0, 1, "z"}}; !reflect.DeepEqual(rows, want) { + t.Fatalf("rows = %+v, want %+v", rows, want) + } + + // One record: same contract on a struct. + one := row{ID: 7, Age: 1, Email: "keep"} + if err := commitRecord(reflect.ValueOf(&one).Elem(), decoded(uint32(300), "new"), plan); !errors.Is(err, errUnassignable) { + t.Fatalf("overflowing record: %v", err) + } + if one != (row{7, 1, "keep"}) { + t.Fatalf("partial write: %+v", one) + } + if err := commitRecord(reflect.ValueOf(&one).Elem(), decoded(uint32(2), "new"), plan); err != nil { + t.Fatal(err) + } + if one != (row{7, 2, "new"}) { + t.Fatalf("record = %+v", one) + } +} + +func TestEncryptRecordRejectsNil(t *testing.T) { + c := &Client{closed: true} + cph := c.DefaultKeyset() + for name, in := range map[string]any{"nil": nil, "nil pointer": (*taggedUser)(nil)} { + if _, err := cph.EncryptRecord(context.Background(), in); err == nil || errors.Is(err, ErrState) { + t.Errorf("EncryptRecord(%s): %v, want a record error", name, err) + } + if _, err := cph.EncryptRecords(context.Background(), in); err == nil || errors.Is(err, ErrState) { + t.Errorf("EncryptRecords(%s): %v, want a record error", name, err) + } + } +} + +func TestKeysetIDRoundTripsCanonicalForm(t *testing.T) { + const s = "6a70bd18-99ac-4650-b104-37eec3a15b09" + id, err := ParseKeysetID(s) + if err != nil { + t.Fatal(err) + } + if got := id.String(); got != s { + t.Fatalf("String() = %q, want %q", got, s) + } + for _, bad := range []string{"", "6a70bd18", "6a70bd18-99ac-4650-b104-37eec3a15b0g", "6a70bd1899ac4650b10437eec3a15b09"} { + if _, err := ParseKeysetID(bad); err == nil { + t.Errorf("ParseKeysetID(%q) accepted", bad) + } + } +} + +func TestSelectorsSpellEveryVariant(t *testing.T) { + id := KeysetID{1, 2, 3} + cases := []struct { + sel KeysetSelector + want map[string]any + }{ + {defaultKeyset{}, map[string]any{"default": map[string]any{}}}, + {KeysetName("acme"), map[string]any{"name": "acme"}}, + {id, map[string]any{"id": id[:]}}, + {anyKeyset{}, map[string]any{"any": map[string]any{}}}, + } + for _, tc := range cases { + if got := tc.sel.selector(); !reflect.DeepEqual(got, tc.want) { + t.Errorf("%T: got %v, want %v", tc.sel, got, tc.want) + } + if _, err := vcffi.Marshal(options(tc.sel)); err != nil { + t.Errorf("%T options do not marshal: %v", tc.sel, err) + } + } +} + +func TestContextNestsToTheLeft(t *testing.T) { + c := MustContext("users/age") + if got := c.value(); got != "users/age" { + t.Fatalf("bare part = %v", got) + } + c, err := c.With(uint64(7)) + if err != nil { + t.Fatal(err) + } + c, err = c.With("eu") + if err != nil { + t.Fatal(err) + } + want := []any{[]any{"users/age", uint64(7)}, "eu"} + if got := c.value(); !reflect.DeepEqual(got, want) { + t.Fatalf("With chain = %v, want %v", got, want) + } + for _, bad := range []any{1.5, true, nil, []any{"x"}, map[string]any{}} { + if _, err := NewContext(bad); err == nil { + t.Errorf("NewContext(%T) accepted", bad) + } + } + if _, err := (Context{}).With("x"); err == nil { + t.Error("an empty context extended") + } +} + +type taggedUser struct { + ID int64 `stash:"-"` + Age uint32 `stash:"context=users/age,index=eq;ore"` + Email string `stash:"context=users/email,index=eq;match,name=email"` + Notes string `stash:"context=users/notes"` + Plain string `stash:"plain"` + NoTag string + hidden string `stash:"context=x"` //nolint:unused // proves unexported fields are skipped +} + +func TestPlanFromTags(t *testing.T) { + plan, err := planFor(reflect.TypeOf(taggedUser{}), recordOptions{}) + if err != nil { + t.Fatal(err) + } + want := []fieldPlan{ + {index: 1, name: "Age", context: "users/age", outputs: []string{"c", "eq", "ore"}}, + {index: 2, name: "email", context: "users/email", outputs: []string{"c", "eq", "match"}}, + {index: 3, name: "Notes", context: "users/notes", outputs: []string{"c"}}, + } + if !reflect.DeepEqual(plan, want) { + t.Fatalf("plan = %+v\nwant %+v", plan, want) + } + + obj, err := planValue(plan, recordOptions{extension: []any{uint64(7)}}) + if err != nil { + t.Fatal(err) + } + age := obj[0].Value.(vcvalue.Object) + if got := age[0].Value; !reflect.DeepEqual(got, []any{"users/age", uint64(7)}) { + t.Fatalf("extended context = %v", got) + } + if _, err := vcffi.Marshal(obj); err != nil { + t.Fatalf("plan does not marshal: %v", err) + } + + for name, bad := range map[string]any{ + "no context": struct { + A int `stash:"index=eq"` + }{}, + "unknown kind": struct { + A int `stash:"context=c,index=fuzzy"` + }{}, + "unknown option": struct { + A int `stash:"context=c,store=true"` + }{}, + "empty context": struct { + A int `stash:"context="` + }{}, + "nothing tagged": struct{ A int }{}, + "not a struct": 42, + "nil type": nil, + "term twice": struct { + A int `stash:"context=c,index=eq;eq"` + }{}, + "duplicate name": struct { + A int `stash:"context=c,name=x"` + B int `stash:"context=c,name=x"` + }{}, + } { + if _, err := PlanFromTags(reflect.TypeOf(bad)); err == nil { + t.Errorf("%s: plan accepted", name) + } + } +} + +// An explicit plan is the tag plan by another route: the same fields give +// the guest the same bytes, and WithPlan's zero value is the tag path. +func TestExplicitPlanIsTheTagPlan(t *testing.T) { + typ := reflect.TypeOf(taggedUser{}) + explicit, err := NewPlan( + FieldPlan{Field: "Age", Context: "users/age", Terms: []TermKind{Equality, Ore}}, + FieldPlan{Field: "Email", Name: "email", Context: "users/email", Terms: []TermKind{Equality, Match}}, + FieldPlan{Field: "Notes", Context: "users/notes"}, + ) + if err != nil { + t.Fatal(err) + } + tagged, err := PlanFromTags(typ) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(explicit.Fields(), tagged.Fields()) { + t.Fatalf("fields differ:\n%+v\n%+v", explicit.Fields(), tagged.Fields()) + } + encode := func(p Plan) []byte { + bound, err := p.bind(typ) + if err != nil { + t.Fatal(err) + } + obj, err := planValue(bound, recordOptions{extension: []any{uint64(7)}}) + if err != nil { + t.Fatal(err) + } + b, err := vcffi.Marshal(obj) + if err != nil { + t.Fatal(err) + } + return b + } + if a, b := encode(explicit), encode(tagged); !bytes.Equal(a, b) { + t.Fatalf("guest input differs:\n%x\n%x", a, b) + } + viaOption, err := planFor(typ, applyOptions([]RecordOption{WithPlan(explicit)})) + if err != nil { + t.Fatal(err) + } + viaTags, err := planFor(typ, applyOptions([]RecordOption{WithPlan(Plan{})})) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(viaOption, viaTags) { + t.Fatalf("bound plans differ:\n%+v\n%+v", viaOption, viaTags) + } + // Fields returns a copy. + explicit.Fields()[0].Context = "changed" + if explicit.Fields()[0].Context != "users/age" { + t.Fatal("Fields exposed the plan's own slice") + } +} + +// A plan can name only exported, direct fields of the struct it binds to, +// and only fields that exist; an untagged struct binds fine under it. +func TestPlanBindsByFieldName(t *testing.T) { + type embedded struct{ Inner string } + type untagged struct { + embedded + Email string + hidden string //nolint:unused // proves unexported fields are refused + } + typ := reflect.TypeOf(untagged{}) + if _, err := PlanFromTags(typ); err == nil { + t.Fatal("untagged struct has a tag plan") + } + ok, err := NewPlan(FieldPlan{Field: "Email", Context: "c"}) + if err != nil { + t.Fatal(err) + } + bound, err := planFor(typ, applyOptions([]RecordOption{WithPlan(ok)})) + if err != nil { + t.Fatal(err) + } + if len(bound) != 1 || bound[0].index != 1 || bound[0].name != "Email" { + t.Fatalf("bound = %+v", bound) + } + for name, field := range map[string]string{ + "missing": "Nope", + "unexported": "hidden", + "promoted": "Inner", + } { + p, err := NewPlan(FieldPlan{Field: field, Context: "c"}) + if err != nil { + t.Fatal(err) + } + if _, err := p.bind(typ); err == nil || !strings.Contains(err.Error(), field) { + t.Errorf("%s: bind error = %v, want one naming %q", name, err, field) + } + } + if _, err := ok.bind(reflect.TypeOf(42)); err == nil { + t.Error("bound to a non-struct") + } +} + +func TestNewPlanRefusesMalformedFields(t *testing.T) { + for name, fields := range map[string][]FieldPlan{ + "no fields": nil, + "no field name": {{Context: "c"}}, + "no context": {{Field: "A"}}, + "unknown kind": {{Field: "A", Context: "c", Terms: []TermKind{TermKind(9)}}}, + "duplicate name": {{Field: "A", Context: "c", Name: "x"}, {Field: "B", Context: "c", Name: "x"}}, + "field twice": {{Field: "A", Name: "x", Context: "c"}, {Field: "A", Name: "y", Context: "d"}}, + "term twice": {{Field: "A", Context: "c", Terms: []TermKind{Equality, Equality}}}, + } { + if _, err := NewPlan(fields...); err == nil { + t.Errorf("%s: plan accepted", name) + } + } + if (Plan{}).Fields() != nil { + t.Error("zero plan has fields") + } +} + +func TestAssignFieldConvertsWithinFamiliesOnly(t *testing.T) { + type flag bool + type name string + type raw []byte + type row struct { + I int + U8 uint8 + F float32 + F64 float64 + S string + B []byte + P *int64 + Bool bool + Flag flag + Name name + Raw raw + M map[string]int + Any any + } + var r row + rv := reflect.ValueOf(&r).Elem() + must := func(field string, v any) { + t.Helper() + if err := assignField(rv.FieldByName(field), v); err != nil { + t.Fatalf("%s <- %T: %v", field, v, err) + } + } + must("I", int64(-5)) + must("U8", uint32(200)) + must("F", float32(1.5)) + must("F", float64(0.5)) // exactly representable: narrows + must("F64", float32(0.1)) + must("S", "s") + must("B", []byte{1}) + must("P", int64(9)) + must("Bool", true) + must("Flag", true) + must("Name", "n") + must("Raw", []byte{2}) + if r.I != -5 || r.U8 != 200 || r.F != 0.5 || r.F64 != float64(float32(0.1)) || r.S != "s" || string(r.B) != "\x01" || *r.P != 9 || + !r.Bool || !bool(r.Flag) || r.Name != "n" || string(r.Raw) != "\x02" { + t.Fatalf("assigned %+v", r) + } + // A sealed none decodes as nil: only a field that can hold one takes it. + r.P, r.B, r.M, r.Any = new(int64), []byte{1}, map[string]int{"a": 1}, 1 + must("P", nil) + must("B", nil) + must("M", nil) + must("Any", nil) + if r.P != nil || r.B != nil || r.M != nil || r.Any != nil { + t.Fatalf("nil did not clear: %+v", r) + } + for _, bad := range []struct { + field string + v any + }{ + {"U8", uint32(300)}, // overflow + {"I", uint64(1)}, // family + {"S", int64(1)}, // kind + {"Bool", "true"}, // kind + {"I", float64(1)}, // family + {"F", float64(0.1)}, // not representable as float32 + {"F", float64(16777217)}, // in range, not representable + {"I", nil}, // a none into a scalar + {"S", nil}, // a none into a scalar + {"Bool", nil}, // a none into a scalar + {"Name", []byte("n")}, // kind + {"Raw", "r"}, // kind + } { + if err := assignField(rv.FieldByName(bad.field), bad.v); !errors.Is(err, errUnassignable) { + t.Errorf("%s <- %v: got %v, want errUnassignable", bad.field, bad.v, err) + } + } +} + +// The request body wipes its buffer when closed, not before: reads up to +// Close see the bytes, Close zeroes them, a read after Close is an error +// rather than zeros, and a second Close is harmless. +func TestRequestBodyWipesOnClose(t *testing.T) { + src := []byte(`{"client_id":"abc"}`) + b := newRequestBody(src) + wipe(src) // the guest wipes its own buffer on return; the copy must not notice + got, err := io.ReadAll(b) + if err != nil || string(got) != `{"client_id":"abc"}` { + t.Fatalf("ReadAll = %q, %v", got, err) + } + if err := b.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if !bytes.Equal(b.buf, make([]byte, len(b.buf))) { + t.Errorf("buffer after Close = %q, want zeros", b.buf) + } + if _, err := b.Read(make([]byte, 1)); !errors.Is(err, errRequestBodyClosed) { + t.Errorf("Read after Close: %v, want errRequestBodyClosed", err) + } + if err := b.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } + // A close before the send is complete fails the send rather than + // letting zeros through as the request. + b = newRequestBody([]byte("0123456789")) + if n, err := b.Read(make([]byte, 4)); n != 4 || err != nil { + t.Fatalf("partial Read = %d, %v", n, err) + } + _ = b.Close() + if _, err := io.ReadAll(b); !errors.Is(err, errRequestBodyClosed) { + t.Errorf("ReadAll after an early Close: %v, want errRequestBodyClosed", err) + } +} + +func TestHeadersRoundTrip(t *testing.T) { + h := http.Header{} + h.Add("Content-Type", "application/json") + h.Add("X-Multi", "a") + h.Add("X-Multi", "b") + buf := encodeHeaders(h) + if string(buf) != "Content-Type: application/json\nX-Multi: a\nX-Multi: b" { + t.Fatalf("encoded %q", buf) + } + back := parseHeaders(append([]byte("garbage line\n"), buf...)) + if got := back.Get("content-type"); got != "application/json" { + t.Fatalf("parsed content-type %q", got) + } + if got := back.Values("X-Multi"); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("parsed multi %v", got) + } +} + +func TestTermsAndLeavesScanAndValue(t *testing.T) { + var eq EqualityTerm + if err := eq.Scan([]byte{1, 2}); err != nil || !eq.Equal(EqualityTerm{1, 2}) { + t.Fatalf("scan/equal: %v %v", err, eq) + } + if eq.Equal(EqualityTerm{1, 3}) { + t.Fatal("unequal terms compared equal") + } + var m MatchTerm = []byte{1, 0, 2, 0} + pos, err := m.Positions() + if err != nil || !reflect.DeepEqual(pos, []uint16{1, 2}) { + t.Fatalf("positions %v %v", pos, err) + } + if _, err := (MatchTerm{1}).Positions(); err == nil { + t.Fatal("odd match term accepted") + } + var s Sealed + if err := s.Scan(nil); err == nil { + t.Fatal("NULL scanned into Sealed") + } + if err := s.Scan("ab"); err != nil || string(s) != "ab" { + t.Fatalf("string scan: %v %q", err, s) + } + src := []byte{7} + if err := s.Scan(src); err != nil { + t.Fatal(err) + } + src[0] = 8 + if s[0] != 7 { + t.Fatal("Scan aliased the driver's slice") + } + v, err := s.Value() + if err != nil || string(v.([]byte)) != "\x07" { + t.Fatalf("Value: %v %v", v, err) + } +} + +func TestLeafSetKeepsStackEncryptLeavesDistinct(t *testing.T) { + ct := map[string]any{ + "a": Sealed{1}, + "b": SealedNone{2}, + "c": SealedEmptySeq{3}, + "d": SealedEmptyMap{4}, + "p": vcvalue.Plain{V: "clear"}, + } + encoded, err := marshalCipherText(ct) + if err != nil { + t.Fatal(err) + } + back, err := unmarshalCipherText(encoded) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(back, ct) { + t.Fatalf("round trip %#v", back) + } + // A vitaminc leaf is not a stack-encrypt node. + if _, err := marshalCipherText(map[string]any{"a": vcvalue.Sealed{1}}); err == nil { + t.Fatal("vcvalue.Sealed accepted as a stack-encrypt leaf") + } + if _, err := vcffi.MarshalCipherText(vcffi.VCValueLeaves(), Sealed{1}); err == nil { + t.Fatal("stackencrypt.Sealed accepted as a vitaminc leaf") + } +} + +// This package's sentinels are the shared table's: a packed status decodes +// to the error this package names for it. +func TestStatusMappingIsTotal(t *testing.T) { + for status, want := range map[uint32]error{ + 1: ErrAuthentication, 2: ErrEncoding, 3: ErrState, 4: ErrInternal, + 5: ErrUnauthorized, 6: ErrForbidden, 7: ErrNotFound, 8: ErrConflict, + 9: ErrTransport, 10: ErrKMS, 11: ErrTerm, 12: ErrForeignKeyset, + } { + if _, _, got := guest.PackedResult(uint64(status)); !errors.Is(got, want) { + t.Errorf("status %d: %v", status, got) + } + } + if _, _, got := guest.PackedResult(99); !errors.Is(got, ErrInternal) { + t.Errorf("unknown status: %v", got) + } +} + +// The Go ordering of ORE and OPE terms agrees with Rust's Ord: testdata +// holds ciphertexts the cllw-ore crate produced, each group in ascending +// plaintext order, and every pair must order the same way here. +func TestTermOrderingAgreesWithRust(t *testing.T) { + f, err := os.Open("testdata/cllw_order.txt") + if err != nil { + t.Fatal(err) + } + defer f.Close() + groups := map[string][][]byte{} + var order []string + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if strings.HasPrefix(line, "#") || line == "" { + continue + } + parts := strings.Fields(line) + kind, typ, raw := parts[0], parts[1], parts[len(parts)-1] + if raw == "-" { + raw = "" + } + term, err := hex.DecodeString(raw) + if err != nil { + t.Fatal(err) + } + key := kind + " " + typ + if _, seen := groups[key]; !seen { + order = append(order, key) + } + groups[key] = append(groups[key], term) + } + if len(order) != 4 { + t.Fatalf("expected 4 vector groups, found %v", order) + } + for _, key := range order { + terms := groups[key] + compare := func(i, j int) int { + if strings.HasPrefix(key, "ope") { + return OpeTerm(terms[i]).Compare(OpeTerm(terms[j])) + } + return OreTerm(terms[i]).Compare(OreTerm(terms[j])) + } + for i := range terms { + for j := range terms { + want := 0 + if i < j { + want = -1 + } else if i > j { + want = 1 + } + if got := compare(i, j); got != want { + t.Errorf("%s: compare(%d, %d) = %d, want %d", key, i, j, got, want) + } + } + } + if strings.HasPrefix(key, "ore") && !OreTerm(terms[0]).Less(OreTerm(terms[1])) { + t.Errorf("%s: Less disagrees with Compare", key) + } + } + // A different length that shares no prefix bytes still orders by the + // first difference, and an empty term is less than any other. + if OreTerm(nil).Compare(OreTerm{1}) != -1 || (OreTerm{1}).Compare(OreTerm(nil)) != 1 || OreTerm(nil).Compare(OreTerm(nil)) != 0 { + t.Error("empty ORE terms do not order by length") + } +} diff --git a/languages/golang/stackencrypt/wasm/README.md b/languages/golang/stackencrypt/wasm/README.md new file mode 100644 index 000000000..9cd0785f8 --- /dev/null +++ b/languages/golang/stackencrypt/wasm/README.md @@ -0,0 +1,6 @@ +# Guest module + +`stack_encrypt_guest.wasm` is a build artefact of the Rust crate in +`../guest`, copied here by `mise run wasm:guest:build`. It is not committed; +the Go package embeds this directory and reports `ErrGuestNotBuilt` from +`NewClient` when the module is absent, and its tests skip. diff --git a/languages/typescript/packages/auth/.cargo/config.toml b/languages/typescript/packages/auth/.cargo/config.toml new file mode 100644 index 000000000..ce83855c4 --- /dev/null +++ b/languages/typescript/packages/auth/.cargo/config.toml @@ -0,0 +1,11 @@ +# macOS dyld's chained-fixups format does not run the module-registration +# constructors that the `ctor` crate emits for each `#[napi]` item, so a +# napi addon built on macOS silently exports only a subset of its classes +# and functions (`require()` returns a near-empty object). Linking with +# `-no_fixup_chains` restores the classic bind-on-load behaviour so every +# `#[napi]` registration runs. +# +# Scoped to the `stack-auth-node` crate (this is the only napi addon in the +# repo). No-op on Linux — CI builds there and is unaffected. +[target.'cfg(target_os = "macos")'] +rustflags = ["-C", "link-arg=-Wl,-no_fixup_chains"] diff --git a/languages/typescript/packages/auth/.gitignore b/languages/typescript/packages/auth/.gitignore new file mode 100644 index 000000000..5b0deaf19 --- /dev/null +++ b/languages/typescript/packages/auth/.gitignore @@ -0,0 +1,11 @@ +target/ +node_modules/ +*.node +npm/*/*.node + +# wasm/ is the output of `wasm-pack build` invoked by `npm run build:wasm`. +# Build artifacts are populated by CI before publish (or locally by the dev). +wasm/ + +# Local `npm pack` tarballs used for offline testing. +cipherstash-auth-*.tgz diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md new file mode 100644 index 000000000..cd21620b8 --- /dev/null +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -0,0 +1,250 @@ +# Changelog + +## 0.44.0 + +### Minor Changes + +- 2f75eca: Add `USAGE_LIMIT_EXCEEDED` and `ORG_NOT_PROVISIONED` to the `AuthFailure` + union. Authentication and token refresh now report CTS usage denials as typed, + non-retryable failures instead of misclassifying them as transient server, + access-denied, or expired-token errors. The failure message is preserved and + the `help` field identifies whether to upgrade the plan or contact support. + +## 0.43.0 + +### Minor Changes + +- 5d46b40: Add the `@cipherstash/auth/next` runtime adapter for federated CTS tokens in + request/response server frameworks — built for the Next.js App Router, but + framework-agnostic by construction (every function operates on WHATWG + `Request`/`Headers` and returns plain data). + + `csFederate` / `csFederationMiddleware` federate-or-reuse a CTS service token in + any writable, in-scope context (middleware, route handler, server action), + persist it to a per-workspace HTTP-only cookie for the cross-request cache, and + hand the freshly minted token to the same-request render via a request header. + `csAuthHeader` reads that warmed token back into a no-federation `AuthStrategy` + that can be driven from a detached callback (e.g. protect-ffi) for the life of + the request — it replays the warmed token and does not refresh, so it is valid + only until that token's TTL expires. `csSanitizeHeaders` strips a + client-supplied warmed-token header on ingress; `csFederationMiddleware` applies + it for you and returns the sanitised `requestHeaders` to forward. + + Built on the `Result`-returning strategy surface — `getToken()` resolves a + `{ data }` / `{ failure }` `Result`. + +## 0.42.0 + +### Minor Changes + +- bc1d158: Add a `CUSTOM` member to the `AuthFailure` union (`type: "CUSTOM"`). This mirrors a new `AuthError::Custom` variant on the Rust side — the serde-style catch-all for an auth error outside the standard set, which a custom auth strategy can surface for its own failures and which an FFI adaptor reconstructs a failure into when its `type` code doesn't map to a specific typed variant. It serializes as `{ type: "CUSTOM", ... }`, so consumers switching on `failure.type` should handle it. + +## 0.41.0 + +### Minor Changes + +- 28fc5b1: **Errors are now returned, not thrown.** Every fallible operation returns a + [`@byteslice/result`](https://www.npmjs.com/package/@byteslice/result) + `Result` — `{ data }` on success, `{ failure }` on a domain error — instead of + throwing. This applies to `getToken()`, the strategy factories + (`AccessKeyStrategy.create`, `AutoStrategy.detect`, + `DeviceSessionStrategy.fromProfile`, `OidcFederationStrategy.create` / + `.createWithStore`), `beginDeviceCodeFlow`, `DeviceCodeResult.pollForToken` / + `openInBrowser`, and `bindClientDevice`. The same applies to the + `@cipherstash/auth/wasm-inline` entry. + + `failure` is a discriminated union (`AuthFailure`) tagged by `type` (the codes + formerly on `err.code`), carrying the live `error: Error`, optional + `help`/`url`, and per-variant payload (e.g. `WORKSPACE_MISMATCH`'s `expected` + / `actual`). Only a genuine internal panic still throws. + + Migration: + + ```ts + // before + try { + const { token } = await strategy.getToken(); + } catch (err) { + if (err.code === "EXPIRED_TOKEN") { + /* … */ + } + } + + // after + const result = await strategy.getToken(); + if (result.failure) { + if (result.failure.type === "EXPIRED_TOKEN") { + /* … */ + } + } else { + const { token } = result.data; + } + ``` + + Two new failure `type`s surface caller/runtime states that previously threw + as bare errors: `ALREADY_CONSUMED` (reusing a consumed `DeviceCodeResult` + handle) and `INTERNAL_ERROR`. + + Adds a runtime dependency on `@byteslice/result` (zero-dependency, MIT). + + **`instanceof` on the strategy classes now returns `false`.** The exported + `AutoStrategy` / `AccessKeyStrategy` / `DeviceSessionStrategy` are thin facades + over the native classes, and the factories hand back the strategy inside + `result.data`, so `result.data instanceof AccessKeyStrategy` is now `false` (it + was `true` on `main`, when the factory returned the instance directly). Gate on + `result.failure` and use `result.data` rather than `instanceof`. + +## 0.40.0 + +### New Features + +- **`OidcFederationStrategy` `baseUrl` override** — both `create` and + `createWithStore` now accept an optional trailing `baseUrl` that pins a single + strategy instance to a specific CTS host, taking precedence over the + `CS_CTS_HOST` environment variable and region service discovery. + + ```ts + OidcFederationStrategy.createWithStore( + workspaceCrn, + getJwt, + loadToken, + saveToken, + "http://localhost:4000" // baseUrl — federate against a mock / self-hosted CTS + ); + ``` + + Unlike `CS_CTS_HOST`, the override is scoped to that strategy alone, so it + doesn't redirect other CTS clients sharing the process (e.g. a `protect-ffi` + encryption client). On `wasm-inline` it's the `baseUrl` field of the options + object (`{ store?, baseUrl? }`); in the wasm runtime — which can't read + `CS_CTS_HOST` from the environment — it's the only way to target a host other + than the region-discovered one. + +## 0.39.0 + +### New Features + +- **`OidcFederationStrategy`** — federate a third-party OIDC JWT (Clerk, + Supabase, …) into a CipherStash CTS service token via `/api/authorise`. + Exposed on both the napi and `wasm-inline` entrypoints, with a `getJwt` + callback that supplies the current third-party token. + + ```ts + const strategy = OidcFederationStrategy.create( + "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY", + getJwt // () => Promise<string> — your current third-party OIDC JWT + ); + const { token } = await strategy.getToken(); + ``` + + The first argument is a workspace CRN: region is derived from it for service + discovery, and the workspace ID is used to verify every federated token — + the same shape as `AccessKeyStrategy`. + + A store-backed variant persists the federated CTS token (e.g. in an HTTP-only + cookie) so it survives across requests without re-federating: + + ```ts + OidcFederationStrategy.createWithStore( + workspaceCrn, + getJwt, + loadToken, + saveToken + ); + ``` + +### Breaking Changes + +- **`AccessKeyStrategy.create(workspaceCrn, accessKey)`** — the first argument + is now a workspace CRN, not a region string. Region is derived from the CRN, + so callers can no longer accidentally configure a strategy whose region + disagrees with the workspace it was pointed at. + + ```ts + // Before (0.38.x) + const strategy = AccessKeyStrategy.create( + "ap-southeast-2.aws", + "CSAKid.secret" + ); + + // After (0.39.0) + const strategy = AccessKeyStrategy.create( + "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY", + "CSAKid.secret" + ); + ``` + + The same change applies to the wasm-inline entry: `AccessKeyStrategy.create(workspaceCrn, accessKey, options?)`. + +- **Per-call workspace verification.** Every issued token's `workspace` JWT + claim is now checked against the CRN. If they differ (e.g. an access key + with rights on multiple workspaces was bound to the wrong CRN), `getToken()` + rejects with a new `WORKSPACE_MISMATCH` error code. Previously the + strategy silently let the caller operate on a different workspace than the + one they specified. + +### Deprecations + +- **`OAuthStrategy` is renamed to `DeviceSessionStrategy`** to make its purpose + — _renewing an existing CTS device session_ via a refresh token — distinct + from _federating a third-party JWT_ (`OidcFederationStrategy`). `OAuthStrategy` + is still exported as a `@deprecated` alias of `DeviceSessionStrategy`, so + existing code keeps working; it will be removed in a future major. + +### New Error Codes + +- `WORKSPACE_MISMATCH` — the JWT decoded cleanly but its `workspace` claim + doesn't match the CRN the strategy was configured with. The accompanying + message identifies both the expected and the token-supplied workspace IDs. +- `INVALID_WORKSPACE_ID` — a token's `workspace` claim could not be parsed + while extracting or verifying it. + +Both `AccessKeyStrategy` and `OidcFederationStrategy` take a workspace CRN, so a +malformed CRN argument is rejected with the existing `INVALID_CRN` code. + +### Changed Error Codes + +- **`OidcFederationStrategy` construction now reports `INVALID_CRN`.** Because it + takes a single workspace CRN instead of separate `region` + `workspaceId` + arguments, a malformed value is now surfaced as `INVALID_CRN` rather than the + previous `INVALID_REGION` / `INVALID_WORKSPACE_ID` codes. Consumers matching on + those codes from the strategy's factories should update accordingly. + +## 0.35.0 + +### New Features + +- **AutoStrategy** — auto-detect credentials from environment variables and the local profile store. + Use `AutoStrategy.detect()` for zero-config auth, or pass explicit values: + ```ts + const strategy = AutoStrategy.detect({ + accessKey: "CSAK...", + workspaceCrn: "crn:...", + }); + const { token, issuer, services } = await strategy.getToken(); + ``` +- **AccessKeyStrategy** — authenticate with a static access key (service-to-service, CI/CD): + ```ts + const strategy = AccessKeyStrategy.create( + "ap-southeast-2.aws", + "CSAKid.secret" + ); + const { token } = await strategy.getToken(); + ``` +- **OAuthStrategy** — authenticate using OAuth refresh tokens persisted to disk: + ```ts + const strategy = OAuthStrategy.fromProfile(); + const { token } = await strategy.getToken(); + ``` +- **TokenResult** — `getToken()` returns `{ token, subject, workspaceId, issuer, services }` with + the bearer credential and decoded JWT claims for identity and service discovery. +- New error codes: `NOT_AUTHENTICATED`, `MISSING_WORKSPACE_CRN`, `INVALID_ACCESS_KEY`, `INVALID_CRN`. + +### Security + +- `TokenResult` and `AutoStrategyOptions` use `OpaqueDebug` to prevent tokens and access keys + from appearing in Rust debug/log output. + +## 0.34.2 + +- Initial release with `beginDeviceCodeFlow()` and `bindClientDevice()`. diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml new file mode 100644 index 000000000..5f14a11a7 --- /dev/null +++ b/languages/typescript/packages/auth/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "stack-auth-node" +version.workspace = true +edition.workspace = true +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +stack-auth = { workspace = true, features = ["http"] } +stack-profile = { workspace = true } +cts-common = { workspace = true } +vitaminc-protected = { workspace = true } +napi = { version = "2", features = ["async", "tokio_rt"] } +napi-derive = "2" +serde_json = "1" +zeroize = { workspace = true } + +[dev-dependencies] +# `stack-auth/test-utils` gives the Rust #[cfg(test)] unit tests the no-timeout +# `http_client`; the mock server itself is now a JS helper (see +# __tests__/helpers/), so the napi crate no longer ships a `test-utils` feature +# or the heavy mocktail/jsonwebtoken/reqwest deps in its published artifact. +stack-auth = { workspace = true, features = ["http", "test-utils"] } +jsonwebtoken = { workspace = true } +mocktail = "0.3.0" +serde_json = "1" +tempfile = "3" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "test-util"] } +url = "2" + +[build-dependencies] +napi-build = "2" diff --git a/languages/typescript/packages/auth/LICENSE b/languages/typescript/packages/auth/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/languages/typescript/packages/auth/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + +<https://polyformproject.org/licenses/internal-use/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/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md new file mode 100644 index 000000000..41a80f822 --- /dev/null +++ b/languages/typescript/packages/auth/README.md @@ -0,0 +1,479 @@ +# @cipherstash/auth + +Authentication bindings for CipherStash services. + +[![npm version](https://img.shields.io/npm/v/@cipherstash/auth?style=for-the-badge)](https://www.npmjs.com/package/@cipherstash/auth) +[![Built by CipherStash](https://raw.githubusercontent.com/cipherstash/meta/refs/heads/main/csbadge.svg)](https://cipherstash.com) + + [Website](https://cipherstash.com) | [Docs](https://cipherstash.com/docs) | [Discord](https://discord.com/invite/5qwXUFb6PB) + +Authentication bindings for [CipherStash](https://cipherstash.com) services. Ships native Node.js bindings for the full surface, and a wasm build for server-side edge runtimes (Supabase Edge Functions, Cloudflare Workers). + +> **Not for direct browser use.** This package is intended for server-side environments — Node.js, Edge Functions, Workers, Bun, Deno. Embedding it directly in a browser bundle would leak the access key into client-side source. The `"browser": false` field in `package.json` makes bundlers like webpack and browserify refuse browser builds; for bundlers that don't honor that convention (esbuild, Vite), don't include `@cipherstash/auth` in client-only chunks. A browser-safe shape that exposes only signed operations (no raw JWT or access key) is tracked as a separate piece of work. + +## Installation + +```bash +npm install @cipherstash/auth +``` + +The package exposes five entries: + +| Entry | Use when | Loads | Surface | +|---|---|---|---| +| `@cipherstash/auth` | **Node.js** | Native napi binding for the host platform | Full surface — device-code flow, profile store, OAuth, `AccessKeyStrategy`, `OidcFederationStrategy` | +| `@cipherstash/auth` | **SSR bundlers** (Vite/Webpack/Next.js targeting Node or server-side rendering) | Sibling-`.wasm` shim from `wasm-pack --target bundler` | `AccessKeyStrategy`, `OidcFederationStrategy` | +| `@cipherstash/auth/wasm` | Explicit opt-in to the sibling-`.wasm` shim | Same as bundler entry above | `AccessKeyStrategy`, `OidcFederationStrategy` | +| `@cipherstash/auth/wasm-inline` | **Supabase Edge Functions / Cloudflare Workers / Bun / Deno via `npm:`** — runtimes that can't auto-bundle a sibling `.wasm` | Inline-bytes shim (wasm embedded as base64) | `AccessKeyStrategy`, `OidcFederationStrategy` | +| `@cipherstash/auth/cookies` | Any runtime with WHATWG `Request`/`Headers` (Edge, Workers, Bun, Deno, Node 18+, Next.js App Router) | Pure-JS helper | `cookieStore(...)` — builds a `TokenStore` from a `Request + Headers` pair | +| `@cipherstash/auth/next` | **Next.js App Router** / request-response server frameworks | Pure-JS adapter over `OidcFederationStrategy` | `csFederationMiddleware`, `csFederate`, `csAuthHeader` — federate-or-reuse + warmed-token handoff ([details](#nextjs-app-router-adapter--cipherstashauthnext)) | + +The wasm bindings expose `AccessKeyStrategy` (static M2M keys) and `OidcFederationStrategy` (federating a third-party OIDC JWT — Clerk, Supabase, … — into a CTS service token). The interactive device-code flow and profile-store loading stay Node-only — they depend on filesystem and browser-launching APIs that can't be ported to wasm. + +The `wasm`, `wasm-inline`, `cookies`, and `next` entries are **ESM-only** — they target Edge/Workers/Deno/Bun runtimes that are ESM-native. From a CommonJS context, load them via dynamic `import()` rather than `require()`. Only the default `@cipherstash/auth` entry has a CJS (`node`) build. + +## Node.js usage — OAuth device-code flow + +```js +const { beginDeviceCodeFlow } = require("@cipherstash/auth"); + +const result = await beginDeviceCodeFlow(region, clientId); + +// Show the user the code and URL +console.log(`Go to ${result.verificationUri} and enter code: ${result.userCode}`); + +// Or open the browser automatically +result.openInBrowser(); + +// Wait for the user to authorize +const auth = await result.pollForToken(); +console.log(`Token expires in ${auth.expiresIn} seconds`); +``` + +The token is saved to `~/.cipherstash/auth.json` automatically and is never exposed to JavaScript. + +## Edge usage — Supabase Edge Functions / Cloudflare Workers + +Pair the `wasm-inline` entry with the `cookies` helper to back the strategy with an HTTP-only cookie. Every Edge invocation gets a fresh strategy, but the cookie keeps the issued service token alive across invocations — so only the first request pays the full round-trip to CTS: + +```ts +// supabase/functions/get-token/index.ts +import { AccessKeyStrategy } from "@cipherstash/auth/wasm-inline"; +import { cookieStore } from "@cipherstash/auth/cookies"; +import { Encryption } from "@cipherstash/stack"; + +Deno.serve(async (req) => { + const responseHeaders = new Headers({ "content-type": "application/json" }); + + const created = AccessKeyStrategy.create( + Deno.env.get("CS_WORKSPACE_CRN")!, // e.g. "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" + Deno.env.get("CS_CLIENT_ACCESS_KEY")!, + { store: cookieStore({ request: req, responseHeaders }) }, + ); + if (created.failure) { + return Response.json({ error: created.failure.type }, { status: 500, headers: responseHeaders }); + } + + // Hand the strategy to a CipherStash SDK — e.g. `Encryption` from + // `@cipherstash/stack` — which acquires and refreshes CTS tokens internally, + // so your code never handles a raw bearer token. You don't call `getToken()` + // yourself. (Need the token itself? See "Working with tokens directly" at the + // end of this README.) + const encryption = new Encryption({ authStrategy: created.data }); + + // ... encrypt / decrypt with `encryption` ... + return Response.json({ ok: true }, { headers: responseHeaders }); +}); +``` + +`supabase/functions/get-token/deno.json`: + +```jsonc +{ + "imports": { + "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.41/wasm-inline", + "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^0.41/cookies" + } +} +``` + +Nothing extra in `supabase/config.toml` — no `static_files`, no asset copying, no bundler plugins. The `wasm-inline` entry embeds the wasm module as base64 inside the JS shim, so it loads with zero runtime config. + +In the recommended flow you never call `getToken()` — the SDK does, internally. If you have a lower-level need for the raw token, see [Working with tokens directly](#working-with-tokens-directly-use-with-care). Factory and token failures are handled the same way; see [Error handling](#error-handling). + +For Cloudflare Workers the shape is identical; env access becomes `env.CS_CLIENT_ACCESS_KEY` instead of `Deno.env.get(...)`. + +### Federating a third-party OIDC JWT — `OidcFederationStrategy` + +When the end user is already signed in with a third-party OIDC provider (Clerk, Supabase, Auth0, …), `OidcFederationStrategy` exchanges their provider JWT for a CTS service token via `/api/authorise` — no access key needed: + +```ts +import { OidcFederationStrategy } from "@cipherstash/auth/wasm-inline"; +import { cookieStore } from "@cipherstash/auth/cookies"; +import { Encryption } from "@cipherstash/stack"; + +Deno.serve(async (req) => { + const responseHeaders = new Headers({ "content-type": "application/json" }); + + const created = OidcFederationStrategy.create( + Deno.env.get("CS_WORKSPACE_CRN")!, // e.g. "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" + // Returns the *current* provider JWT — re-invoked on every re-federation. + () => getClerkSessionToken(req), + { store: cookieStore({ request: req, responseHeaders }) }, + ); + if (created.failure) { + return Response.json({ error: created.failure.type }, { status: 500, headers: responseHeaders }); + } + + // As above, pass the strategy to a CipherStash SDK rather than calling + // `getToken()` yourself — the SDK owns token acquisition and refresh. + const encryption = new Encryption({ authStrategy: created.data }); + + // ... encrypt / decrypt with `encryption` ... + return Response.json({ ok: true }, { headers: responseHeaders }); +}); +``` + +`/api/authorise` issues no CTS refresh token, so when the cached CTS token expires `OidcFederationStrategy` re-federates — it calls `getJwt` again for a fresh provider JWT. Pass a `getJwt` that returns a live token each time (e.g. wrapping the provider SDK), not a value captured once. The same API is available on the Node-native entry: `const { OidcFederationStrategy } = require("@cipherstash/auth")`. + +### Caching with `cookieStore` + +`cookieStore({ request, responseHeaders })` returns a `TokenStore`: + +- `load()` parses the `Cookie:` header from the request, finds `cs_token` (configurable via `name`), base64url-decodes it, and returns the JSON the strategy stored last time. +- `save(json)` happens automatically after every successful refresh / initial auth — `cookieStore` appends a `Set-Cookie` header to `responseHeaders` with the JSON base64url-encoded as the value, `HttpOnly`, `SameSite=Lax`, and `Max-Age` derived from the token's `expires_at` minus a 30-second safety margin. + +Available options: + +| Option | Default | Notes | +|---|---|---| +| `request` | — required — | Incoming `Request` to read the cookie from | +| `responseHeaders` | — required — | Outgoing `Headers` to append `Set-Cookie` to | +| `name` | `"cs_token"` | Cookie name | +| `domain` | unset | `Domain` attribute (host-only by default) | +| `path` | `"/"` | `Path` attribute | +| `secure` | `true` | Set `false` only for localhost HTTP dev | +| `httpOnly` | `true` | Prevents JS access — keep this on | +| `sameSite` | `"Lax"` | `"Strict"` / `"Lax"` / `"None"` | +| `expirySafetyMarginSeconds` | `30` | Seconds subtracted from `expires_at` when computing `Max-Age` | + +The base64url encoding skirts RFC 6265's cookie-value char range, which would otherwise reject the `"` characters present in raw JSON. + +### Rolling your own store + +The `store` field accepts any `{ load, save }`-shaped object — Redis, KV stores, an in-memory `Map`, anything you'd reach for: + +```ts +const strategy = AccessKeyStrategy.create(workspaceCrn, accessKey, { + store: { + async load() { return await redis.get("cs:token"); /* string | null */ }, + async save(json: string) { await redis.set("cs:token", json); }, + }, +}); +``` + +Errors thrown inside `load` / `save` are caught and logged via `console.warn` — the strategy treats them as cache misses and falls back to fresh authentication. + +### Why the explicit sub-path + +Bare `@cipherstash/auth` works in Node (resolves to native napi) and in wasm-aware bundlers (Vite/Webpack handle the sibling-`.wasm` import natively). + +It does **not** work in Deno-resolving-`npm:` runtimes (Supabase Edge, Cloudflare Workers via `npm:`). Deno applies the `node` exports condition for `npm:` specifiers — it emulates Node for npm packages — which routes the bare import to the napi loader. That loader is a CJS module without statically-resolvable ESM named exports, so it errors at boot. There's no condition Deno applies for `npm:` packages that Node ESM doesn't, so we can't route the two apart in the exports map. The `wasm-inline` sub-path bypasses the conditional walk entirely. + +Trade-off for inline: ~27% larger JS payload (~726KB vs ~572KB raw wasm + JS shim) and ~50ms cold-start vs streaming compile. Acceptable for an auth surface that runs once per worker boot. + +### Bundler users (Vite / Webpack / Next.js) + +Bare import is the right shape — these bundlers understand the sibling-`.wasm` reference and emit it as an asset: + +```ts +import { AccessKeyStrategy } from "@cipherstash/auth"; +``` + +If your bundler doesn't handle `.wasm` imports, fall back to `@cipherstash/auth/wasm-inline`. All three entries expose identical APIs. + +## API + +### Node — `beginDeviceCodeFlow(region, clientId)` + +Starts the OAuth 2.0 Device Authorization flow. Returns a `Promise<DeviceCodeResult>`. + +#### `DeviceCodeResult` + +| Property / Method | Description | +|---|---| +| `userCode` | The code the user enters at the verification URI | +| `verificationUri` | The URL the user visits to authorize | +| `verificationUriComplete` | URL with the code pre-filled | +| `expiresIn` | Seconds until the device code expires | +| `openInBrowser()` | Opens the verification URI in the default browser | +| `pollForToken()` | Polls until the user completes authorization. Returns `Promise<AuthResult>` | + +#### `AuthResult` + +| Property | Description | +|---|---| +| `expiresAt` | Absolute epoch timestamp (seconds) when the token expires | +| `expiresIn` | Seconds until the token expires | + +### Edge — `AccessKeyStrategy` + +| Method | Description | +|---|---| +| `AccessKeyStrategy.create(workspaceCrn, accessKey, options?)` | Build a strategy from a workspace CRN and access key (returns a `Result`). Region is derived from the CRN. Pass `{ store }` to back it with a persistent cache. The strategy verifies every issued token's `workspace` claim against the CRN — mismatch surfaces as `failure.type === "WORKSPACE_MISMATCH"`. | +| `strategy.getToken()` | Retrieve a valid token, refreshing as needed. Resolves to `{ data: TokenResult }` or `{ failure }`. | + +`TokenResult` is `{ token, subject, workspaceId, issuer, services }`. + +`options.store` accepts any `{ load, save }`-shaped object: + +```ts +interface TokenStore { + load(): Promise<string | null | undefined>; // null = cache miss + save(json: string): Promise<void>; +} +``` + +The strategy calls `load` on cold start (no in-memory token); if it returns a still-fresh JSON, the strategy reuses it. Otherwise it hits CTS for a fresh token and writes it back via `save`. Stale tokens trigger a refresh and the refreshed token is persisted. + +### Edge — `cookieStore` + +Helper that returns a `TokenStore` backed by an HTTP-only cookie. Works in any runtime that exposes WHATWG `Request` / `Headers`. See the [Caching with `cookieStore`](#caching-with-cookiestore) section above for the option reference. + +## Error handling + +Every fallible operation returns a [`@byteslice/result`](https://www.npmjs.com/package/@byteslice/result) `Result` instead of throwing: `{ data }` on success, `{ failure }` on a domain error. Check `result.failure` — no `try/catch` needed: + +```ts +const result = await strategy.getToken(); +if (result.failure) { + console.error(result.failure.type); // e.g. "EXPIRED_TOKEN" + console.error(result.failure.error.message); // human-readable description + console.error(result.failure.help); // actionable hint, when available +} else { + use(result.data.token); // result.data: TokenResult +} +``` + +`failure` is a discriminated union — narrow on `type` to reach per-variant fields: + +```ts +const created = AccessKeyStrategy.create(workspaceCrn, accessKey); +if (created.failure) { + if (created.failure.type === "WORKSPACE_MISMATCH") { + console.error(`expected ${created.failure.expected}, got ${created.failure.actual}`); + } + return; +} +const strategy = created.data; +``` + +Failure `type`s: `INVALID_ACCESS_KEY`, `ACCESS_DENIED`, `EXPIRED_TOKEN`, `INVALID_GRANT`, `INVALID_CLIENT`, `INVALID_REGION`, `INVALID_URL`, `INVALID_TOKEN`, `USAGE_LIMIT_EXCEEDED`, `ORG_NOT_PROVISIONED`, `SERVER_ERROR`, `REQUEST_ERROR`, `NOT_AUTHENTICATED`, `MISSING_WORKSPACE_CRN`, `INVALID_CRN`, `WORKSPACE_MISMATCH`, `INVALID_WORKSPACE_ID`, `ALREADY_CONSUMED`, `INTERNAL_ERROR`, `CUSTOM`, `STORE_ERROR`. Each `failure` also carries the live `error: Error` and optional `help`/`url`. Only a genuine internal panic still throws. + +`USAGE_LIMIT_EXCEEDED` means the organisation has exhausted its allowance for the current billing period. Retrying will not clear it — the plan has to be upgraded from the CipherStash dashboard first. + +`ORG_NOT_PROVISIONED` means the organisation is not set up for usage tracking at all. There is no plan to upgrade; contact CipherStash support. + +> **Migrating from the throw-based API (0.40.x and earlier):** replace +> `try { const t = await s.getToken(); … } catch (err) { err.code }` +> with `const r = await s.getToken(); if (r.failure) { r.failure.type } else { r.data }`. +> Factories (`AccessKeyStrategy.create`, `AutoStrategy.detect`, +> `OidcFederationStrategy.create`, `DeviceSessionStrategy.fromProfile`) now +> return a `Result` too, so unwrap `.data` before use. + +## Working with tokens directly (use with care) + +The recommended integration is to hand your strategy to a CipherStash SDK — e.g. +`Encryption` from `@cipherstash/stack`, as shown above. The SDK calls +`getToken()` internally and manages refresh, so your code never handles a raw +credential. + +If you have a lower-level need, `getToken()` returns the bearer token directly. +Treat it as a secret: never log it, return it to a browser, or persist it +outside a secure store. + +```ts +const result = await strategy.getToken(); +if (result.failure) { + console.error(result.failure.type); + return; +} +const { token, workspaceId, services } = result.data; +// `token` is the bearer credential — send it as `Authorization: Bearer ${token}` +// to a CTS service (e.g. ZeroKMS at `services.zerokms`). +``` + +`result.data` is `{ token, subject, workspaceId, issuer, services }`, where +`services` is a plain object (e.g. `{ zerokms: "https://..." }`). See +[Error handling](#error-handling) for the failure arm. + +## Next.js App Router adapter — `@cipherstash/auth/next` + +The `@cipherstash/auth/next` entry adapts `OidcFederationStrategy` to +request/response server frameworks. It's built for the Next.js App Router but is +framework-agnostic by construction — every function takes a WHATWG `Request` / +`Headers` and returns plain data, so it works anywhere you can read a request and +write response headers. + +**The model.** Federate a third-party OIDC JWT into a CTS service token where the +request is both *in scope* and *able to write cookies* (middleware, route +handlers, server actions), then: + +- **persist** the token to a per-workspace, `HttpOnly` cookie (`cs_token_<workspace-id>`) — the cross-request cache, so later requests reuse it instead of re-federating; +- **warm** the current request's render by handing the freshly minted token forward on a request header — a `Set-Cookie` written *now* isn't readable in the *same* request, so the render can't see the cookie you just set. + +### Middleware — federate, warm, refresh + +`csFederationMiddleware` **throws on federation failure** — including the +ordinary signed-out case, where there's no JWT to federate. Catch it, or every +unauthenticated request 500s in middleware: + +```ts +// middleware.ts +import { NextResponse } from "next/server"; +import { csFederationMiddleware, csSanitizeHeaders } from "@cipherstash/auth/next"; + +export async function middleware(request: Request) { + const responseHeaders = new Headers(); // the refreshed cookie is appended here + + let requestHeaders: Headers; + try { + ({ requestHeaders } = await csFederationMiddleware({ + request, + responseHeaders, + workspaceCrn: process.env.CS_WORKSPACE_CRN!, // "crn:<region>:<workspace-id>" + getJwt: () => getSessionJwt(), // your provider's *current* JWT (Clerk, Supabase, …) + })); + } catch { + // Signed out, or federation failed — let the request through unwarmed: + // `csAuthHeader` returns null downstream and the render falls back. Still + // strip the header, or a client-supplied one would reach the render forgeable. + requestHeaders = csSanitizeHeaders(request); + } + + // Deliver the warmed token (if any) to this request's render... + const response = NextResponse.next({ request: { headers: requestHeaders } }); + + // ...and copy the refreshed `Set-Cookie` onto the response. + responseHeaders.forEach((value, key) => response.headers.append(key, value)); + return response; +} +``` + +`requestHeaders` is a clone of the incoming headers with any *inbound* +`x-cs-cts-token` deleted and the freshly minted one set — the strip is done by +the library, not left to your wiring. See +[Security](#security--the-warmed-token-header-is-not-authenticated) for why that +matters. + +### Reading the warmed token — Server Components, Route Handlers, protect-ffi + +`csAuthHeader(headers)` reads the token the middleware warmed into an +`AuthStrategy`. The header is read *eagerly* and closed over, so the returned +strategy is safe to drive from a detached callback (e.g. protect-ffi). It returns +`null` when there's no warmed token, so you can fall back to a cold federation or +render a signed-out state. + +> **This strategy does not refresh.** Unlike `OidcFederationStrategy` and +> `AccessKeyStrategy`, it hands back the *same* token on every `getToken()` — it +> is valid only until that token's TTL expires, after which downstream ZeroKMS +> calls fail with no refresh path. Treat it as request-scoped: a detached +> callback *within* the request is fine, but don't cache the strategy across +> requests — re-read the header on the next one. + +```ts +import { headers } from "next/headers"; +import { csAuthHeader } from "@cipherstash/auth/next"; +import { Encryption } from "@cipherstash/stack"; + +export async function loadSecret() { + const strategy = csAuthHeader(await headers()); + if (!strategy) throw new Error("no warmed token — signed out, or middleware didn't run"); + + // Hand the strategy to a CipherStash SDK — it calls getToken() as needed. + // (The warmed strategy itself never refreshes; it's good for this request.) + const encryption = new Encryption({ authStrategy: strategy }); + // ... encrypt / decrypt with `encryption` ... +} +``` + +### Without the warmed-header handoff — `csFederate` + +If you only need a token inside a single writable, in-scope context — a route +handler that both authenticates *and* does the work — skip the middleware handoff +and call `csFederate` directly. It returns a `TokenResult` and throws on failure: + +```ts +// app/api/data/route.ts +import { csFederate } from "@cipherstash/auth/next"; + +export async function GET(request: Request) { + const responseHeaders = new Headers(); + const token = await csFederate({ + request, + responseHeaders, + workspaceCrn: process.env.CS_WORKSPACE_CRN!, + getJwt: () => getSessionJwt(), + }); + return Response.json({ workspaceId: token.workspaceId }, { headers: responseHeaders }); +} +``` + +### Security — the warmed-token header is not authenticated + +`csAuthHeader` reads an opaque base64url(JSON) payload from a request header; it +validates the *shape* of the `TokenResult`, but the payload is **not** +cryptographically authenticated. A forged header is therefore accepted as long as +it's well-formed — and since the validated shape includes an arbitrary `services` +map, a forgery can point your app at an **attacker-controlled ZeroKMS endpoint**. +The exposure is data and key exfiltration, not merely acting as the wrong +identity. + +Only trust it where the inbound, client-supplied header is stripped before the +request reaches your code. `csFederationMiddleware` does this for you — its +`requestHeaders` is built by `csSanitizeHeaders(request)`, which deletes any +inbound `x-cs-cts-token` — but that only covers requests the middleware actually +runs on: + +- **Every** path from which `csAuthHeader` is reachable must be sanitised. Next.js + middleware `matcher`s routinely exclude paths (static assets, some API routes); + an excluded-but-reachable path is a forgery hole. +- On the signed-out / federation-error path, `csFederationMiddleware` throws, so + call `csSanitizeHeaders(request)` yourself — as the middleware example above + does in its `catch`. + +Cryptographically pinning the payload to the app (AEAD seal/open with an app-held +key), so an un-stripped header still can't be forged, is tracked in CIP-3112. +Until that lands, the ingress strip is the *only* thing standing between an +un-matched route and a forged token — treat CIP-3112 as a prerequisite for a +production rollout rather than a nice-to-have. + +### API + +| Export | Description | +|---|---| +| `csFederationMiddleware(options)` | Federate-or-reuse in middleware. Returns `{ result, requestHeaders, headerName, headerValue }` — forward `requestHeaders` — and appends the refreshed cookie to `responseHeaders`. Throws on failure (incl. signed out). | +| `csFederate(options)` | Federate-or-reuse in any writable, in-scope context. Returns a `TokenResult`; throws on failure. | +| `csSanitizeHeaders(source, options?)` | Clone a `Request`/`Headers` with the warmed-token header **deleted**. The ingress strip that makes `csAuthHeader` trustworthy — use on every path it's reachable from. | +| `csAuthHeader(headers, options?)` | Read the warmed token into a no-federation `AuthStrategy`, or `null` if absent. The strategy does **not** refresh — request-scoped only. | +| `csTokenCookieName(workspaceId)` | The per-workspace cookie name, `cs_token_<workspaceId>`. | +| `CS_TOKEN_HEADER` | The default warmed-token request header, `x-cs-cts-token`. | +| `encodeTokenHeader` / `decodeTokenHeader` | The opaque base64url(JSON) header codec (used internally; exported for advanced wiring). | + +`options` (shared by `csFederate` and `csFederationMiddleware`): + +| Option | Default | Notes | +|---|---|---| +| `request` | — required — | Incoming `Request` (reads the token cookie) | +| `responseHeaders` | — required — | Outgoing `Headers` (the refreshed cookie is appended as `Set-Cookie`) | +| `workspaceCrn` | — required — | `crn:<region>:<workspace-id>` | +| `getJwt` | — required — | Returns the *current* third-party OIDC JWT (re-invoked on every re-federation) | +| `baseUrl` | region discovery | Pin federation to a specific CTS host / mock | +| `cookieName` | `cs_token_<workspace-id>` | Override the per-workspace cookie name | +| `secure` | `true` | Cookie `Secure` flag — set `false` only for localhost HTTP dev | +| `sameSite` | `"Lax"` | Cookie `SameSite` | +| `headerName` | `x-cs-cts-token` | (`csFederationMiddleware` only) request header to carry the warmed token | + +## License + +Distributed under the [PolyForm Internal Use License 1.0.0](https://polyformproject.org/licenses/internal-use/1.0.0). A full copy is bundled with this package as [`LICENSE`](./LICENSE). diff --git a/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts b/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts new file mode 100644 index 000000000..2f03abfe3 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts @@ -0,0 +1,194 @@ +import { execFileSync } from 'child_process' +import { copyFileSync, mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it } from 'vitest' + +// The public type surface is split across two files: `index.d.ts` is +// hand-written and re-exports the NAPI-RS–generated `native.d.ts`, adding the +// declarations napi can't emit (`AuthError`, `AuthErrorCode`, `OAuthStrategy`). +// +// vitest erases type-only imports and never runs `tsc`, so a broken split — +// the `export * from "./native"` re-export removed, the `AuthError` interface +// or `OAuthStrategy` alias deleted, `native.d.ts` failing to resolve, or a +// generated symbol no longer reaching the package entrypoint — would compile +// green through the rest of the suite while silently breaking every consumer's +// `import { ... } from "@cipherstash/auth"`. +// +// This test is the only thing that exercises the contract the way a real +// consumer does: it type-checks an importing module against the package, by +// name, through the `package.json` `exports` map. It asserts behaviour (does a +// consumer compile?) rather than grepping the declaration file for strings. + +const packageDir = join(__dirname, '..') +// Run the locally-installed tsc as a script under the current node, so this is +// cross-platform (no shell, no `.bin` shim) and pinned to the devDependency. +const tscBin = require.resolve('typescript/bin/tsc') + +// A consumer that imports — and *uses*, so nothing is elided — the public +// Result surface: the success types (`TokenResult`), the `AuthFailure` +// discriminated union + `AuthErrorCode`, the strategy classes (as values, to +// call their `Result`-returning factories/methods), and the `OAuthStrategy` +// runtime alias. It exercises narrowing on both arms of a `Result` and the +// per-variant `WORKSPACE_MISMATCH` payload — so a broken return type, a missing +// failure variant, or an unresolved `@byteslice/result` / `./native` re-export +// fails the typecheck. +const CONSUMER = ` +import type { AuthFailure, AuthErrorCode, TokenResult } from "@cipherstash/auth"; +import { + AutoStrategy, + DeviceSessionStrategy, + OAuthStrategy, +} from "@cipherstash/auth"; + +const _alias: typeof DeviceSessionStrategy = OAuthStrategy; + +function handle(failure: AuthFailure): AuthErrorCode { + if (failure.type === "WORKSPACE_MISMATCH") { + const _e: string = failure.expected; + const _a: string = failure.actual; + void _e; + void _a; + } + return failure.type; +} + +async function run() { + const detected = AutoStrategy.detect(); + if (detected.failure) { + void handle(detected.failure); + return; + } + const result = await detected.data.getToken(); + if (result.failure) { + void handle(result.failure); + } else { + const token: TokenResult = result.data; + const _t: string = token.token; + void _t; + } +} + +void run; +void _alias; +` + +// Node16 resolution makes tsc honour the package's `exports` map (the "node" +// condition resolves to `index.d.ts`), so this verifies the real entrypoint a +// consumer hits — not just a relative path into the file. +const baseCompilerOptions = { + target: 'ES2020', + module: 'Node16', + moduleResolution: 'Node16', + strict: true, + esModuleInterop: true, + skipLibCheck: true, + noEmit: true, + baseUrl: '.', +} + +// Type-check CONSUMER against whatever `@cipherstash/auth` resolves to at +// `pkgDir`. Returns whether tsc accepted it and its combined output. +function typecheckConsumerAgainst(pkgDir: string): { + ok: boolean + output: string +} { + const dir = mkdtempSync(join(tmpdir(), 'cs-auth-tscheck-')) + writeFileSync(join(dir, 'consumer.ts'), CONSUMER) + writeFileSync( + join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + ...baseCompilerOptions, + paths: { '@cipherstash/auth': [pkgDir] }, + }, + files: ['consumer.ts'], + }), + ) + + try { + const output = execFileSync( + process.execPath, + [tscBin, '-p', join(dir, 'tsconfig.json')], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ) + return { ok: true, output } + } catch (err) { + const e = err as { stdout?: Buffer | string; stderr?: Buffer | string } + return { ok: false, output: `${e.stdout ?? ''}${e.stderr ?? ''}` } + } +} + +describe('consumer typecheck (index.d.ts -> native.d.ts split)', () => { + it('a consumer importing from @cipherstash/auth type-checks', () => { + const { ok, output } = typecheckConsumerAgainst(packageDir) + expect(ok, `tsc reported type errors:\n${output}`).toBe(true) + }) + + it('rejects a consumer when the split is broken (guard has teeth)', () => { + // Mirror the real package so module resolution is identical (same + // package.json/`exports`), but replace index.d.ts with a stub that drops + // the `export * from "./native"` re-export and the hand-written + // declarations. The consumer must now fail to compile — proving this + // harness actually goes red when the split breaks, rather than only + // passing when everything is intact. + const brokenPkg = mkdtempSync(join(tmpdir(), 'cs-auth-broken-')) + copyFileSync( + join(packageDir, 'package.json'), + join(brokenPkg, 'package.json'), + ) + writeFileSync(join(brokenPkg, 'index.d.ts'), 'export {};\n') + + const { ok } = typecheckConsumerAgainst(brokenPkg) + expect(ok, 'tsc should reject a consumer when the split is broken').toBe( + false, + ) + }) +}) + +describe('runtime re-export contract (index.js)', () => { + it('OAuthStrategy is the same runtime value as DeviceSessionStrategy', () => { + // The typecheck above (noEmit) only proves the *type* alias resolves. The + // runtime alias `module.exports.OAuthStrategy = native.DeviceSessionStrategy` + // in index.js is exercised by nothing else, so load the real entrypoint and + // assert it: drop that line and `import { OAuthStrategy }` silently becomes + // `undefined` for consumers. + const mod = require('../index.js') as typeof import('../index') + expect(mod.OAuthStrategy).toBeDefined() + expect(mod.OAuthStrategy).toBe(mod.DeviceSessionStrategy) + }) +}) + +describe('publish contract (npm pack)', () => { + it('packs the declarations required by index.d.ts', () => { + // index.d.ts does `export * from "./native"`, so native.d.ts MUST ship in + // the tarball or every published consumer's import dangles on a missing + // file. The typecheck resolves against the source tree, not the packed + // output, so this is the only guard on the `files` allowlist. + const out = execFileSync('npm', ['pack', '--json', '--dry-run'], { + cwd: packageDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + const packed = ( + JSON.parse(out) as Array<{ files: Array<{ path: string }> }> + )[0].files.map((f) => f.path) + expect(packed).toEqual( + expect.arrayContaining(['index.d.ts', 'native.d.ts']), + ) + }) + + it('bundles the LICENSE (README links to it; repo URL is private)', () => { + // The README's license link points at the private repo, unreachable from + // npmjs.org — so a copy must ride in the tarball. Guard the `files` entry. + const out = execFileSync('npm', ['pack', '--json', '--dry-run'], { + cwd: packageDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + const packed = ( + JSON.parse(out) as Array<{ files: Array<{ path: string }> }> + )[0].files.map((f) => f.path) + expect(packed).toContain('LICENSE') + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/cookies.test.ts b/languages/typescript/packages/auth/__tests__/cookies.test.ts new file mode 100644 index 000000000..0c3230494 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/cookies.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' +import { cookieStore } from '../cookies.mjs' + +function makeRequest(cookieHeader?: string): Request { + return new Request('https://example.com/', { + headers: cookieHeader ? { cookie: cookieHeader } : {}, + }) +} + +function tokenJson(overrides: Record<string, unknown> = {}): string { + return JSON.stringify({ + access_token: 'test-jwt', + token_type: 'Bearer', + expires_at: Math.floor(Date.now() / 1000) + 3600, + refresh_token: null, + region: null, + client_id: null, + device_instance_id: null, + ...overrides, + }) +} + +function extractCookieValue(setCookie: string): string { + // Set-Cookie format: "name=value; Path=/; ..." + const firstPair = setCookie.split(';')[0] + return firstPair.slice(firstPair.indexOf('=') + 1) +} + +describe('cookieStore.load', () => { + it('returns null when no cookie header is present', async () => { + const store = cookieStore({ + request: makeRequest(), + responseHeaders: new Headers(), + }) + expect(await store.load()).toBeNull() + }) + + it("returns null when the cookie name isn't set", async () => { + const store = cookieStore({ + request: makeRequest('other=value'), + responseHeaders: new Headers(), + }) + expect(await store.load()).toBeNull() + }) + + it('decodes the base64url-encoded value on the round trip', async () => { + const responseHeaders = new Headers() + const save = cookieStore({ request: makeRequest(), responseHeaders }) + const json = tokenJson() + await save.save(json) + const setCookie = responseHeaders.get('set-cookie')! + + const load = cookieStore({ + request: makeRequest(`cs_token=${extractCookieValue(setCookie)}`), + responseHeaders: new Headers(), + }) + expect(await load.load()).toBe(json) + }) + + it('ignores a corrupt base64url value (returns null)', async () => { + const store = cookieStore({ + request: makeRequest('cs_token=not-valid-base64!@#'), + responseHeaders: new Headers(), + }) + expect(await store.load()).toBeNull() + }) +}) + +describe('cookieStore.save', () => { + it('writes a Set-Cookie header on responseHeaders', async () => { + const responseHeaders = new Headers() + const store = cookieStore({ request: makeRequest(), responseHeaders }) + await store.save(tokenJson()) + expect(responseHeaders.get('set-cookie')).toMatch(/^cs_token=/) + }) + + it('base64url-encodes the value (no quotes in the cookie)', async () => { + const responseHeaders = new Headers() + const store = cookieStore({ request: makeRequest(), responseHeaders }) + await store.save(tokenJson()) + const setCookie = responseHeaders.get('set-cookie')! + // RFC 6265 token-char range: no `"`, `,`, `;`, or `\` in the cookie value. + const value = extractCookieValue(setCookie) + expect(value).toMatch(/^[A-Za-z0-9_-]+$/) + }) + + it('computes Max-Age from expires_at minus the safety margin', async () => { + const responseHeaders = new Headers() + const store = cookieStore({ + request: makeRequest(), + responseHeaders, + expirySafetyMarginSeconds: 30, + }) + const expiresIn = 3600 + const expiresAt = Math.floor(Date.now() / 1000) + expiresIn + await store.save(tokenJson({ expires_at: expiresAt })) + const setCookie = responseHeaders.get('set-cookie')! + const maxAgeMatch = setCookie.match(/Max-Age=(\d+)/) + expect(maxAgeMatch).not.toBeNull() + const maxAge = Number(maxAgeMatch![1]) + // Allow ±2s tolerance for the clock advancing during the test + expect(maxAge).toBeGreaterThanOrEqual(expiresIn - 30 - 2) + expect(maxAge).toBeLessThanOrEqual(expiresIn - 30) + }) + + it("omits Max-Age when expires_at can't be parsed", async () => { + const responseHeaders = new Headers() + const store = cookieStore({ request: makeRequest(), responseHeaders }) + await store.save('not valid json') + const setCookie = responseHeaders.get('set-cookie')! + expect(setCookie).not.toMatch(/Max-Age=/) + }) + + it('honours custom cookie attributes', async () => { + const responseHeaders = new Headers() + const store = cookieStore({ + request: makeRequest(), + responseHeaders, + name: 'custom_name', + path: '/api', + domain: 'example.com', + secure: true, + sameSite: 'Strict', + }) + await store.save(tokenJson()) + const setCookie = responseHeaders.get('set-cookie')! + expect(setCookie).toMatch(/^custom_name=/) + expect(setCookie).toContain('Path=/api') + expect(setCookie).toContain('Domain=example.com') + expect(setCookie).toContain('Secure') + expect(setCookie).toContain('SameSite=Strict') + }) + + it('HttpOnly is on by default; can be disabled', async () => { + const onResponseHeaders = new Headers() + const onStore = cookieStore({ + request: makeRequest(), + responseHeaders: onResponseHeaders, + }) + await onStore.save(tokenJson()) + expect(onResponseHeaders.get('set-cookie')).toContain('HttpOnly') + + const offResponseHeaders = new Headers() + const offStore = cookieStore({ + request: makeRequest(), + responseHeaders: offResponseHeaders, + httpOnly: false, + }) + await offStore.save(tokenJson()) + expect(offResponseHeaders.get('set-cookie')).not.toContain('HttpOnly') + }) + + it('rejects sameSite:None without secure (browsers drop the cookie)', () => { + expect(() => + cookieStore({ + request: makeRequest(), + responseHeaders: new Headers(), + sameSite: 'None', + secure: false, + }), + ).toThrow(/sameSite.*None.*requires.*secure/i) + }) + + it('allows sameSite:None when secure is set', async () => { + const responseHeaders = new Headers() + const store = cookieStore({ + request: makeRequest(), + responseHeaders, + sameSite: 'None', + secure: true, + }) + await store.save(tokenJson()) + const setCookie = responseHeaders.get('set-cookie')! + expect(setCookie).toContain('SameSite=None') + expect(setCookie).toContain('Secure') + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts new file mode 100644 index 000000000..b6c937583 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { DeviceCodeResult } from '../index' +import { MockCtsServer } from './helpers/mock-cts-server' + +const { beginDeviceCodeFlow } = + require('../index.js') as typeof import('../index') + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let server: MockCtsServer + +async function startServer(): Promise<MockCtsServer> { + const s = await MockCtsServer.start() + s.mockDeviceCodeEndpoint() + return s +} + +// The production `beginDeviceCodeFlow` resolves its auth host from CS_CTS_HOST +// (set in `beforeEach` below), so no test-only base-URL override is needed. +async function beginFlow(): Promise<DeviceCodeResult> { + const r = await beginDeviceCodeFlow('ap-southeast-2.aws', 'test-client') + if (r.failure) { + expect.unreachable(`beginDeviceCodeFlow failed: ${r.failure.type}`) + } + return r.data +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('device code flow (TypeScript / vitest)', () => { + // ---------- Error enrichment (no server needed) ---------- + + it('attaches .type for INVALID_REGION', async () => { + const r = await beginDeviceCodeFlow('not-a-region', 'test-client') + expect(r.failure?.error).toBeInstanceOf(Error) + expect(r.failure?.type).toBe('INVALID_REGION') + }) + + // ---------- Tests that need the mock server ---------- + + describe('with mock server', () => { + let savedHost: string | undefined + + beforeEach(async () => { + server = await startServer() + savedHost = process.env.CS_CTS_HOST + process.env.CS_CTS_HOST = server.baseUrl + }) + + afterEach(async () => { + if (savedHost === undefined) { + delete process.env.CS_CTS_HOST + } else { + process.env.CS_CTS_HOST = savedHost + } + await server.close() + }) + + it('exposes getter fields on DeviceCodeResult', async () => { + const result = await beginFlow() + + expect(result.userCode).toBe('ABCD-EFGH') + expect(result.verificationUri).toBe('http://example.com/activate') + expect(result.verificationUriComplete).toBe( + 'http://example.com/activate?user_code=ABCD-EFGH', + ) + expect(result.expiresIn).toBe(900) + }) + + it('pollForToken resolves with auth metadata on success', async () => { + server.mockTokenEndpoint() + const result = await beginFlow() + const pr = await result.pollForToken() + if (pr.failure) { + expect.unreachable(`pollForToken failed: ${pr.failure.type}`) + } + const auth = pr.data + + expect(auth.expiresAt).toBeGreaterThan(0) + expect(auth.expiresIn).toBeGreaterThanOrEqual(3598) + expect(auth.expiresIn).toBeLessThanOrEqual(3600) + }) + + it('pollForToken fails on second call (consumed handle)', async () => { + server.mockTokenEndpoint() + const result = await beginFlow() + + // First call succeeds — consumes the handle + const first = await result.pollForToken() + if (first.failure) { + expect.unreachable(`first pollForToken failed: ${first.failure.type}`) + } + + // Second call should surface a failure + const second = await result.pollForToken() + expect(second.failure?.error).toBeInstanceOf(Error) + expect(second.failure?.type).toBe('ALREADY_CONSUMED') + expect(second.failure?.error.message).toMatch(/already consumed/i) + }) + + it('pollForToken fails with enriched ACCESS_DENIED', async () => { + server.mockTokenEndpointError('access_denied') + const result = await beginFlow() + + const pr = await result.pollForToken() + expect(pr.failure?.error).toBeInstanceOf(Error) + expect(pr.failure?.type).toBe('ACCESS_DENIED') + }) + + it('pollForToken fails with enriched EXPIRED_TOKEN', async () => { + server.mockTokenEndpointError('expired_token') + const result = await beginFlow() + + const pr = await result.pollForToken() + expect(pr.failure?.error).toBeInstanceOf(Error) + expect(pr.failure?.type).toBe('EXPIRED_TOKEN') + }) + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/factory-result.test.ts b/languages/typescript/packages/auth/__tests__/factory-result.test.ts new file mode 100644 index 000000000..b1efcc600 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/factory-result.test.ts @@ -0,0 +1,128 @@ +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +// Runtime coverage for the sync strategy factories through index.js. napi +// defines class statics as non-writable, so a Result-wrapping bug there is +// invisible to both the Rust tests (which test the native layer directly) and +// the compile-only consumer-typecheck test — the factories must be exercised +// at runtime, on both arms, via the public entry point. +const { + AutoStrategy, + AccessKeyStrategy, + DeviceSessionStrategy, + OAuthStrategy, + beginDeviceCodeFlow, +} = require('../index.js') as typeof import('../index') + +const VALID_CRN = 'crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY' +// Well-formed key (`CSAK<key-id>.<secret>`) — factories only parse the shape; +// no network call happens until getToken(). +const VALID_KEY = 'CSAKtestKeyId.testKeySecret' + +const SAVED_ENV_KEYS = [ + 'CS_CLIENT_ACCESS_KEY', + 'CS_WORKSPACE_CRN', + 'CS_CONFIG_PATH', +] as const +let savedEnv: Partial<Record<(typeof SAVED_ENV_KEYS)[number], string>> +let configDir: string + +beforeEach(() => { + savedEnv = {} + for (const key of SAVED_ENV_KEYS) { + savedEnv[key] = process.env[key] + delete process.env[key] + } + // Point the profile store at an empty temp dir so ambient ~/.cipherstash + // state can't leak into detection. + configDir = mkdtempSync(join(tmpdir(), 'cs-auth-test-')) + process.env.CS_CONFIG_PATH = configDir +}) + +afterEach(() => { + rmSync(configDir, { recursive: true, force: true }) + for (const key of SAVED_ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } +}) + +describe('AccessKeyStrategy.create', () => { + it('returns a failure for a malformed CRN', () => { + const r = AccessKeyStrategy.create('not-a-crn', VALID_KEY) + expect(r.failure?.type).toBe('INVALID_CRN') + expect(r.failure?.error).toBeInstanceOf(Error) + // The FFI sentinel must be stripped from the surfaced message. + expect(r.failure?.error.message).not.toContain('__CS_FAIL__') + }) + + it('returns a failure for a malformed access key', () => { + const r = AccessKeyStrategy.create(VALID_CRN, 'not-a-key') + expect(r.failure?.type).toBe('INVALID_ACCESS_KEY') + }) + + it('returns { data } wrapping a usable strategy on success', () => { + const r = AccessKeyStrategy.create(VALID_CRN, VALID_KEY) + if (r.failure) { + expect.unreachable(`create failed: ${r.failure.type}`) + } + // A bare (unwrapped) native instance would have no `data` key — this + // assertion is what distinguishes a wrapped Result from the native value. + expect(typeof r.data.getToken).toBe('function') + }) +}) + +describe('AutoStrategy.detect', () => { + it('returns a NOT_AUTHENTICATED failure when no credentials exist', () => { + const r = AutoStrategy.detect() + expect(r.failure?.type).toBe('NOT_AUTHENTICATED') + expect(r.failure?.help).toBeTruthy() + }) + + it('returns a MISSING_WORKSPACE_CRN failure for an access key without a CRN', () => { + const r = AutoStrategy.detect({ accessKey: VALID_KEY }) + expect(r.failure?.type).toBe('MISSING_WORKSPACE_CRN') + }) + + it('returns { data } wrapping a usable strategy for explicit options', () => { + const r = AutoStrategy.detect({ + accessKey: VALID_KEY, + workspaceCrn: VALID_CRN, + }) + if (r.failure) { + expect.unreachable(`detect failed: ${r.failure.type}`) + } + expect(typeof r.data.getToken).toBe('function') + }) +}) + +describe('DeviceSessionStrategy.fromProfile', () => { + it('returns a STORE_ERROR failure when the profile store is empty', () => { + const r = DeviceSessionStrategy.fromProfile() + expect(r.failure?.type).toBe('STORE_ERROR') + expect(r.failure?.error).toBeInstanceOf(Error) + }) + + it('is what the deprecated OAuthStrategy alias points at', () => { + expect(OAuthStrategy).toBe(DeviceSessionStrategy) + }) +}) + +describe('toFailure re-throw contract', () => { + it('propagates a non-sentinel error instead of converting it to a failure', async () => { + // The migration's safety premise: only sentineled domain errors become + // `{ failure }`; anything else (a genuine bug/panic) must keep propagating + // as an error. napi's argument coercion throws a plain, sentinel-free + // TypeError — drive it through a wrapped async function and require a + // rejection, not a resolved Result. A `toFailure` that swallowed + // non-sentinel errors into failures would resolve here and fail the test. + await expect( + beginDeviceCodeFlow(123 as unknown as string, 'cli'), + ).rejects.toThrow(/Failed to convert/) + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts b/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts new file mode 100644 index 000000000..0211e9dc1 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts @@ -0,0 +1,192 @@ +// A zero-dependency mock CTS / auth server for the vitest suite, replacing the +// Rust `MockAuthServer` (which required the `test-utils` Cargo feature and +// pulled `mocktail`/`reqwest`/`jsonwebtoken` into the native module). Tests +// point the bindings at it via `CS_CTS_HOST` — the production strategies all +// resolve their host as `override → CS_CTS_HOST → discovery`, so no test-only +// Rust seam is needed. +// +// Each `mock*` method registers a one-shot-ish handler for a route; the server +// keeps the last handler registered per route. `clearMocks()` drops them all. + +import { createServer, type Server } from 'node:http' +import { mintJwt } from './test-fixtures' + +type Handler = (body: string) => { status: number; json: unknown } + +export class MockCtsServer { + #server: Server + #routes = new Map<string, Handler>() + #baseUrl = '' + + private constructor(server: Server) { + this.#server = server + } + + /** Start a mock server on an ephemeral port and resolve once it's listening. */ + static async start(): Promise<MockCtsServer> { + const mock = new MockCtsServer( + createServer((req, res) => { + const handler = mock.#routes.get(`${req.method} ${req.url}`) + if (!handler) { + res.writeHead(404, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ error: 'no mock registered' })) + return + } + let body = '' + req.on('data', (chunk) => { + body += chunk + }) + req.on('end', () => { + const { status, json } = handler(body) + res.writeHead(status, { 'content-type': 'application/json' }) + res.end(JSON.stringify(json)) + }) + }), + ) + + await new Promise<void>((resolve, reject) => { + // Surface bind/listen failures as a rejected promise instead of hanging + // the suite until a timeout. Drop the listener once we're listening so it + // doesn't intercept later runtime errors. + const onError = (err: Error) => reject(err) + mock.#server.once('error', onError) + mock.#server.listen(0, '127.0.0.1', () => { + mock.#server.removeListener('error', onError) + resolve() + }) + }) + const addr = mock.#server.address() + if (addr === null || typeof addr === 'string') { + throw new Error('mock server did not bind a TCP port') + } + mock.#baseUrl = `http://127.0.0.1:${addr.port}` + return mock + } + + /** The base URL of the running mock server (e.g. `http://127.0.0.1:12345`). */ + get baseUrl(): string { + return this.#baseUrl + } + + /** Stop the server and free its port. Call from `afterEach`. */ + async close(): Promise<void> { + await new Promise<void>((resolve, reject) => { + this.#server.close((err) => (err ? reject(err) : resolve())) + }) + } + + #on(method: string, path: string, handler: Handler): void { + this.#routes.set(`${method} ${path}`, handler) + } + + // --- Device-code flow --- + + mockDeviceCodeEndpoint(): void { + this.#on('POST', '/oauth/device/code', () => ({ + status: 200, + json: { + device_code: 'test_device_code', + user_code: 'ABCD-EFGH', + verification_uri: 'http://example.com/activate', + verification_uri_complete: + 'http://example.com/activate?user_code=ABCD-EFGH', + expires_in: 900, + }, + })) + } + + mockTokenEndpoint(): void { + this.#on('POST', '/oauth/device/token', () => ({ + status: 200, + json: { + access_token: mintJwt(), + token_type: 'Bearer', + expires_in: 3600, + }, + })) + } + + mockTokenEndpointError(code: string): void { + this.#on('POST', '/oauth/device/token', () => ({ + status: 400, + json: { + error: code, + error_description: `${code} occurred`, + }, + })) + } + + // --- OIDC federation --- + + /** + * `expiry` is seconds-until-expiry; CTS returns the JWT `exp` as an ABSOLUTE + * Unix epoch (CIP-3233), so convert to `now + expiry` — otherwise a freshly + * federated token reads as already expired. Default 3600. The minted JWT's + * `exp` is set to the same absolute value, so the token and the response + * envelope stay consistent. + */ + mockAuthorizeEndpoint(expiry = 3600): void { + this.#on('POST', '/api/authorise', () => { + const exp = Math.floor(Date.now() / 1000) + expiry + return { + status: 200, + json: { + accessToken: mintJwt({ exp }), + expiry: exp, + }, + } + }) + } + + /** + * Like {@link mockAuthorizeEndpoint}, but mints the federated token with a + * `workspace` claim of `workspace` — pass one that differs from the strategy's + * CRN to make `getToken()` fail workspace verification with + * `WORKSPACE_MISMATCH`. + */ + mockAuthorizeEndpointWithWorkspace(workspace: string, expiry = 3600): void { + this.#on('POST', '/api/authorise', () => { + const exp = Math.floor(Date.now() / 1000) + expiry + return { + status: 200, + json: { + accessToken: mintJwt({ exp, workspace }), + expiry: exp, + }, + } + }) + } + + mockAuthorizeEndpointError(): void { + this.#on('POST', '/api/authorise', () => ({ + status: 500, + json: { error: 'federation failed' }, + })) + } + + // --- ZeroKMS create-client (device provisioning) --- + + mockCreateClientEndpoint(): void { + this.#on('POST', '/create-client', () => ({ + status: 200, + json: { + id: '00000000-0000-0000-0000-000000000001', + dataset_id: '00000000-0000-0000-0000-000000000099', + name: 'test-device', + description: 'test-device', + client_key: 'dGVzdC1rZXktbWF0ZXJpYWw=', + }, + })) + } + + mockCreateClientConflict(): void { + this.#on('POST', '/create-client', () => ({ + status: 409, + json: { error: 'conflict' }, + })) + } + + clearMocks(): void { + this.#routes.clear() + } +} diff --git a/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts b/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts new file mode 100644 index 000000000..5cfe8d313 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts @@ -0,0 +1,70 @@ +// Test fixtures that replace the Rust `test-utils` helpers (`saveTestToken` and +// the JWT minting inside `MockAuthServer`). The stack-auth claim readers never +// verify signatures — `decode_jwt_payload` splits on `.`, base64url-decodes the +// payload segment and deserialises it — so a JWT only needs three segments and a +// well-formed payload, hence zero crypto deps. + +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +export const WORKSPACE_ID = 'ZVATKW3VHMFG27DY' + +function base64url(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url') +} + +/** + * Mint an unsigned-but-well-formed JWT (`<header>.<payload>.sig`). Mirrors the + * claims of `mock_auth_server::test_jwt`; `claims` overrides/extends them (e.g. + * to add a `services` claim). Only the payload is ever read: the header and + * signature segments just have to be present for the three-segment check. + */ +export function mintJwt(claims: Record<string, unknown> = {}): string { + const now = Math.floor(Date.now() / 1000) + const header = base64url({ alg: 'HS256', typ: 'JWT' }) + const payload = base64url({ + iss: 'https://cts.example.com/', + sub: 'CS|test-user', + aud: 'test-audience', + iat: now, + exp: now + 3600, + workspace: WORKSPACE_ID, + org_id: 'org_test_default', + scope: '', + ...claims, + }) + return `${header}.${payload}.sig` +} + +/** + * Write a CTS auth token into a profile directory — the JS replacement for the + * Rust `saveTestToken` napi helper. Mirrors `ProfileStore::init_workspace` plus + * `save_with_mode("auth.json", .., 0o600)`: creates `workspaces/<ws>/`, points + * the `current_workspace` file at it (what `bind_client_device` reads to locate + * the token), and writes the token JSON. The token carries `services.zerokms` + * so device provisioning can reach the mock ZeroKMS endpoint. + * + * Pair with `process.env.CS_CONFIG_PATH = profileDir` so the production + * `bindClientDevice()` resolves this directory. + */ +export function saveTestToken( + profileDir: string, + zerokmsBaseUrl: string, +): void { + const now = Math.floor(Date.now() / 1000) + const jwt = mintJwt({ + aud: 'legacy-aud-value', + services: { zerokms: zerokmsBaseUrl }, + }) + const tokenJson = { + access_token: jwt, + token_type: 'Bearer', + expires_at: now + 3600, + } + const wsDir = join(profileDir, 'workspaces', WORKSPACE_ID) + mkdirSync(wsDir, { recursive: true }) + writeFileSync(join(profileDir, 'current_workspace'), WORKSPACE_ID) + writeFileSync(join(wsDir, 'auth.json'), JSON.stringify(tokenJson), { + mode: 0o600, + }) +} diff --git a/languages/typescript/packages/auth/__tests__/next.test.ts b/languages/typescript/packages/auth/__tests__/next.test.ts new file mode 100644 index 000000000..861926225 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/next.test.ts @@ -0,0 +1,394 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + CS_TOKEN_HEADER, + csAuthHeader, + csFederate, + csFederationMiddleware, + csSanitizeHeaders, + csTokenCookieName, + decodeTokenHeader, + encodeTokenHeader, +} from '../next.mjs' + +// Mock the wasm strategy so these tests exercise the adapter's OWN wiring +// (cookie persistence, header encode/decode, warmed-token reads) deterministically +// and offline. The real federate-or-reuse + cookie round-trip against a CTS +// server is covered by `oidc-cookie-roundtrip.test.ts`. `vi.hoisted` lets the +// hoisted `vi.mock` factory reference these fns without a top-level await. +const { getToken, free, create } = vi.hoisted(() => ({ + getToken: vi.fn(), + free: vi.fn(), + create: vi.fn(), +})) + +vi.mock('../wasm-inline.mjs', () => ({ + OidcFederationStrategy: { + create: ( + crn: string, + getJwt: () => unknown, + options: { store?: { load(): unknown; save(json: string): unknown } }, + ) => create(crn, getJwt, options), + }, +})) + +const WORKSPACE_ID = 'ZVATKW3VHMFG27DY' +const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}` + +function tokenResult(overrides: Record<string, unknown> = {}) { + return { + token: 'header.payload.signature', + subject: `CS|${WORKSPACE_ID}`, + workspaceId: WORKSPACE_ID, + issuer: 'https://cts.example.com', + services: { zerokms: 'https://zerokms.example.com' }, + ...overrides, + } +} + +function requestWith(cookie?: string): Request { + return new Request('https://example.com/', { + headers: cookie ? { cookie } : {}, + }) +} + +/** A request carrying a client-forged warmed-token header (the attacker case). */ +function requestWithForgedHeader( + headerName = CS_TOKEN_HEADER, + overrides: Record<string, unknown> = { + token: 'forged', + services: { zerokms: 'https://attacker.example.com' }, + }, +): Request { + return new Request('https://example.com/', { + headers: { + 'x-unrelated': 'keep-me', + [headerName]: encodeTokenHeader(tokenResult(overrides)), + }, + }) +} + +beforeEach(() => { + getToken.mockReset() + free.mockReset() + create.mockReset() + // Default fake strategy: writes the token to the store (so cookie wiring is + // exercised) and returns a Result-wrapped TokenResult. Both `create()` and + // `getToken()` return `@byteslice/result` Results (`{ data }` on success), + // matching the real wasm surface the adapter unwraps. + create.mockImplementation( + ( + _crn: string, + _getJwt: () => unknown, + options: { store?: { save(json: string): unknown } }, + ) => { + getToken.mockImplementation(async () => { + await options.store?.save( + JSON.stringify({ + access_token: 'header.payload.signature', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }), + ) + return { data: tokenResult() } + }) + return { data: { getToken, free } } + }, + ) +}) + +describe('csTokenCookieName', () => { + it('is per-workspace', () => { + expect(csTokenCookieName(WORKSPACE_ID)).toBe(`cs_token_${WORKSPACE_ID}`) + }) +}) + +describe('encode/decodeTokenHeader', () => { + it('round-trips a TokenResult through the opaque header payload', () => { + const r = tokenResult() + const decoded = decodeTokenHeader(encodeTokenHeader(r)) + expect(decoded).toEqual(r) + }) + + it('produces a header-safe value (no base64 +/=/ chars)', () => { + const v = encodeTokenHeader(tokenResult()) + expect(v).not.toMatch(/[+/=]/) + }) +}) + +describe('csFederate', () => { + it('builds the strategy with the cookie-backed store and persists the token', async () => { + const responseHeaders = new Headers() + const result = await csFederate({ + request: requestWith(), + responseHeaders, + workspaceCrn: WORKSPACE_CRN, + getJwt: () => 'jwt', + baseUrl: 'https://cts.example.com', + cookieName: csTokenCookieName(WORKSPACE_ID), + }) + + expect(result.workspaceId).toBe(WORKSPACE_ID) + // baseUrl threaded through to the strategy. + expect(create).toHaveBeenCalledWith(WORKSPACE_CRN, expect.any(Function), { + store: expect.anything(), + baseUrl: 'https://cts.example.com', + }) + // The cookie was written under the per-workspace name. + const setCookie = responseHeaders.get('set-cookie') + expect(setCookie).toMatch(new RegExp(`^cs_token_${WORKSPACE_ID}=`)) + // wasm resources released. + expect(free).toHaveBeenCalledOnce() + }) + + it('defaults the cookie name to cs_token_<workspaceId> from the CRN when omitted', async () => { + const responseHeaders = new Headers() + await csFederate({ + request: requestWith(), + responseHeaders, + workspaceCrn: WORKSPACE_CRN, + getJwt: () => 'jwt', + // cookieName intentionally omitted — must NOT collapse to `cs_token`. + }) + expect(responseHeaders.get('set-cookie')).toMatch( + new RegExp(`^cs_token_${WORKSPACE_ID}=`), + ) + }) + + it("throws the failure's error and still frees the strategy (finally path)", async () => { + // Override the default fake so getToken resolves to a `{ failure }` Result — + // the new-API failure mode. csFederate must unwrap it, throw the live + // `failure.error`, and still run the try/finally's `free()`. + create.mockImplementation(() => { + getToken.mockResolvedValue({ + failure: { + type: 'SERVER_ERROR', + error: new Error('federation failed'), + }, + }) + return { data: { getToken, free } } + }) + + await expect( + csFederate({ + request: requestWith(), + responseHeaders: new Headers(), + workspaceCrn: WORKSPACE_CRN, + getJwt: () => 'jwt', + }), + ).rejects.toThrow('federation failed') + // free() still ran despite the throw. + expect(free).toHaveBeenCalledOnce() + }) + + it('propagates a getToken rejection and still frees the strategy (wasm panic path)', async () => { + // Distinct from the `{ failure }` domain-error path above: a genuine wasm + // panic (unbranded error, or calling getToken after free) rejects the + // promise rather than resolving to `{ failure }` — `settleGetToken` re-throws + // it. The `await` must propagate the rejection while the finally still frees. + create.mockImplementation(() => { + getToken.mockRejectedValue(new Error('null pointer passed to rust')) + return { data: { getToken, free } } + }) + + await expect( + csFederate({ + request: requestWith(), + responseHeaders: new Headers(), + workspaceCrn: WORKSPACE_CRN, + getJwt: () => 'jwt', + }), + ).rejects.toThrow('null pointer passed to rust') + expect(free).toHaveBeenCalledOnce() + }) + + it("throws the failure's error when strategy creation fails (no free)", async () => { + // `create()` itself can fail (e.g. INVALID_CRN) — it returns `{ failure }` + // before any strategy is allocated, so csFederate throws without calling + // free() (there's nothing to release). + create.mockImplementation(() => ({ + failure: { type: 'INVALID_CRN', error: new Error('bad crn') }, + })) + + await expect( + csFederate({ + request: requestWith(), + responseHeaders: new Headers(), + workspaceCrn: WORKSPACE_CRN, + getJwt: () => 'jwt', + }), + ).rejects.toThrow('bad crn') + expect(free).not.toHaveBeenCalled() + }) +}) + +describe('csFederationMiddleware', () => { + it('returns the warmed token header alongside the result', async () => { + const responseHeaders = new Headers() + const { result, headerName, headerValue } = await csFederationMiddleware({ + request: requestWith(), + responseHeaders, + workspaceCrn: WORKSPACE_CRN, + getJwt: () => 'jwt', + cookieName: csTokenCookieName(WORKSPACE_ID), + }) + + expect(headerName).toBe(CS_TOKEN_HEADER) + expect(decodeTokenHeader(headerValue)).toEqual(result) + // Both caches populated: cookie (cross-request) + header (same-request). + expect(responseHeaders.get('set-cookie')).toBeTruthy() + }) + + it('honours a custom header name', async () => { + const { headerName } = await csFederationMiddleware({ + request: requestWith(), + responseHeaders: new Headers(), + workspaceCrn: WORKSPACE_CRN, + getJwt: () => 'jwt', + cookieName: csTokenCookieName(WORKSPACE_ID), + headerName: 'x-warm', + }) + expect(headerName).toBe('x-warm') + }) + + it('returns requestHeaders with the forged inbound header replaced by the minted one', async () => { + // The forgery invariant lives in the library: a client-supplied + // `x-cs-cts-token` must never survive into the render, even though the + // freshly minted `set()` would overwrite it anyway on this (success) path. + const { result, requestHeaders } = await csFederationMiddleware({ + request: requestWithForgedHeader(), + responseHeaders: new Headers(), + workspaceCrn: WORKSPACE_CRN, + getJwt: () => 'jwt', + cookieName: csTokenCookieName(WORKSPACE_ID), + }) + + // Downstream reads the real token, not the attacker's. + const warmed = csAuthHeader(requestHeaders) + expect((await warmed?.getToken())?.data).toEqual(result) + expect((await warmed?.getToken())?.data?.services.zerokms).toBe( + 'https://zerokms.example.com', + ) + // Unrelated inbound headers are preserved for the render. + expect(requestHeaders.get('x-unrelated')).toBe('keep-me') + }) + + it('strips the forged header under a custom header name too', async () => { + const { requestHeaders } = await csFederationMiddleware({ + request: requestWithForgedHeader('x-warm'), + responseHeaders: new Headers(), + workspaceCrn: WORKSPACE_CRN, + getJwt: () => 'jwt', + cookieName: csTokenCookieName(WORKSPACE_ID), + headerName: 'x-warm', + }) + expect( + (await csAuthHeader(requestHeaders, { headerName: 'x-warm' })?.getToken()) + ?.data?.token, + ).toBe('header.payload.signature') + }) +}) + +describe('csSanitizeHeaders', () => { + it('deletes a client-forged warmed-token header and keeps the rest', () => { + const sanitized = csSanitizeHeaders(requestWithForgedHeader()) + expect(sanitized.get(CS_TOKEN_HEADER)).toBeNull() + expect(csAuthHeader(sanitized)).toBeNull() + expect(sanitized.get('x-unrelated')).toBe('keep-me') + }) + + it('accepts a Headers as well as a Request', () => { + const sanitized = csSanitizeHeaders(requestWithForgedHeader().headers) + expect(csAuthHeader(sanitized)).toBeNull() + }) + + it('does not mutate the source headers', () => { + const headers = requestWithForgedHeader().headers + csSanitizeHeaders(headers) + // Request headers are immutable in the fetch spec, so a delete on the source + // would throw rather than silently strip — assert the clone is what changed. + expect(headers.get(CS_TOKEN_HEADER)).not.toBeNull() + }) + + it('strips only the named header', () => { + const request = new Request('https://example.com/', { + headers: { + [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult()), + 'x-warm': encodeTokenHeader(tokenResult()), + }, + }) + const sanitized = csSanitizeHeaders(request, { headerName: 'x-warm' }) + expect(sanitized.get('x-warm')).toBeNull() + expect(sanitized.get(CS_TOKEN_HEADER)).not.toBeNull() + }) +}) + +describe('csAuthHeader', () => { + it('reads a warmed token from the request header into a no-federation strategy', async () => { + const headers = new Headers({ + [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult()), + }) + const strategy = csAuthHeader(headers) + expect(strategy).not.toBeNull() + expect(strategy?.requiresFederation).toBe(false) + // Warmed strategy mirrors a real strategy: getToken() resolves a `{ data }` + // Result, not a bare TokenResult. + expect((await strategy?.getToken())?.data?.workspaceId).toBe(WORKSPACE_ID) + }) + + it('reads eagerly at construction (later header mutation is ignored)', async () => { + const headers = new Headers({ + [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult({ token: 'first' })), + }) + const strategy = csAuthHeader(headers) + headers.set( + CS_TOKEN_HEADER, + encodeTokenHeader(tokenResult({ token: 'second' })), + ) + expect((await strategy?.getToken())?.data?.token).toBe('first') + }) + + it('returns null when no warmed token is present (cold fallback)', () => { + expect(csAuthHeader(new Headers())).toBeNull() + }) + + it('returns null on a malformed header rather than throwing', () => { + const headers = new Headers({ [CS_TOKEN_HEADER]: 'not-valid-base64url!!' }) + expect(csAuthHeader(headers)).toBeNull() + }) + + it('rejects a structurally-incomplete TokenResult (spoof/partial payload)', () => { + // Missing workspaceId/subject/issuer — decodes fine but isn't a TokenResult. + const partial = new Headers({ + [CS_TOKEN_HEADER]: encodeTokenHeader({ + token: 'header.payload.signature', + } as never), + }) + expect(csAuthHeader(partial)).toBeNull() + + // services present but not a string→string map. + const badServices = new Headers({ + [CS_TOKEN_HEADER]: encodeTokenHeader( + tokenResult({ services: { zerokms: 123 } }) as never, + ), + }) + expect(csAuthHeader(badServices)).toBeNull() + + // services present but EMPTY — a federated token always has >=1 endpoint. + const emptyServices = new Headers({ + [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult({ services: {} })), + }) + expect(csAuthHeader(emptyServices)).toBeNull() + + // Field PRESENT but empty string — distinct from missing; must still reject + // (isTokenResult's `.length === 0` branch). workspaceId is representative. + const emptyField = new Headers({ + [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult({ workspaceId: '' })), + }) + expect(csAuthHeader(emptyField)).toBeNull() + }) + + it('honours a custom header name', async () => { + const headers = new Headers({ 'x-warm': encodeTokenHeader(tokenResult()) }) + expect(csAuthHeader(headers, { headerName: 'x-warm' })).not.toBeNull() + expect(csAuthHeader(headers)).toBeNull() + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts b/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts new file mode 100644 index 000000000..c657e3c43 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { cookieStore } from '../cookies.mjs' +import { MockCtsServer } from './helpers/mock-cts-server' + +const { OidcFederationStrategy } = + require('../index.js') as typeof import('../index') + +const WORKSPACE_ID = 'ZVATKW3VHMFG27DY' +const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}` + +let server: MockCtsServer +let savedHost: string | undefined + +beforeEach(async () => { + server = await MockCtsServer.start() + savedHost = process.env.CS_CTS_HOST + process.env.CS_CTS_HOST = server.baseUrl +}) + +afterEach(async () => { + if (savedHost === undefined) { + delete process.env.CS_CTS_HOST + } else { + process.env.CS_CTS_HOST = savedHost + } + await server.close() +}) + +/** Extract the `name=value` pair from a `Set-Cookie` header. */ +function cookiePair(setCookie: string): string { + return setCookie.split(';')[0] +} + +/** A `Request` carrying the given `Cookie:` header (or none). */ +function requestWith(cookie?: string): Request { + return new Request('https://example.com/', { + headers: cookie ? { cookie } : {}, + }) +} + +/** Unwrap a `createWithStore` Result, failing the test if it returned a failure. */ +function mustCreateWithStore( + ...args: Parameters<typeof OidcFederationStrategy.createWithStore> +): InstanceType<typeof OidcFederationStrategy> { + const cr = OidcFederationStrategy.createWithStore(...args) + if (cr.failure) { + expect.unreachable(`createWithStore failed: ${cr.failure.type}`) + } + return cr.data +} + +describe('OidcFederationStrategy + cookieStore round-trip', () => { + it('writes the federated CTS token to a Set-Cookie header', async () => { + server.mockAuthorizeEndpoint() + const responseHeaders = new Headers() + const store = cookieStore({ request: requestWith(), responseHeaders }) + + const strategy = mustCreateWithStore( + WORKSPACE_CRN, + () => Promise.resolve('header.payload.signature'), + store.load, + store.save, + ) + const r = await strategy.getToken() + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`) + } + + expect(r.data.workspaceId).toBe(WORKSPACE_ID) + const setCookie = responseHeaders.get('set-cookie') + expect(setCookie).toBeTruthy() + expect(setCookie).toMatch(/^cs_token=/) + }) + + it('reuses a cached token from the cookie without re-federating', async () => { + // First request federates and writes the cookie. + server.mockAuthorizeEndpoint() + const firstHeaders = new Headers() + const firstStore = cookieStore({ + request: requestWith(), + responseHeaders: firstHeaders, + }) + const first = mustCreateWithStore( + WORKSPACE_CRN, + () => Promise.resolve('header.payload.signature'), + firstStore.load, + firstStore.save, + ) + await first.getToken() + const cookie = cookiePair(firstHeaders.get('set-cookie')!) + + // Second request carries the cookie. Federation would fail (500) and + // getJwt would throw — proving the token came from the cookie. + server.clearMocks() + server.mockAuthorizeEndpointError() + const secondStore = cookieStore({ + request: requestWith(cookie), + responseHeaders: new Headers(), + }) + const second = mustCreateWithStore( + WORKSPACE_CRN, + () => Promise.reject(new Error('getJwt must not be called')), + secondStore.load, + secondStore.save, + ) + + const r = await second.getToken() + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`) + } + expect(r.data.workspaceId).toBe(WORKSPACE_ID) + }) + + it('re-federates when the cookie holds an expired token', async () => { + // First request federates a token that is immediately expired (expiry 0). + server.mockAuthorizeEndpoint(0) + const firstHeaders = new Headers() + const firstStore = cookieStore({ + request: requestWith(), + responseHeaders: firstHeaders, + }) + const first = mustCreateWithStore( + WORKSPACE_CRN, + () => Promise.resolve('header.payload.signature'), + firstStore.load, + firstStore.save, + ) + await first.getToken() + const cookie = cookiePair(firstHeaders.get('set-cookie')!) + + // Second request carries the expired cookie. getToken() must re-federate + // — calling getJwt again — rather than serving the stale token. + server.clearMocks() + server.mockAuthorizeEndpoint() + let getJwtCalls = 0 + const secondStore = cookieStore({ + request: requestWith(cookie), + responseHeaders: new Headers(), + }) + const second = mustCreateWithStore( + WORKSPACE_CRN, + () => { + getJwtCalls += 1 + return Promise.resolve('header.payload.signature') + }, + secondStore.load, + secondStore.save, + ) + + const r = await second.getToken() + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`) + } + expect(r.data.workspaceId).toBe(WORKSPACE_ID) + expect(getJwtCalls).toBe(1) + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts new file mode 100644 index 000000000..1470d9a0d --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -0,0 +1,362 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { MockCtsServer } from './helpers/mock-cts-server' + +const { OidcFederationStrategy } = + require('../index.js') as typeof import('../index') + +const WORKSPACE_ID = 'ZVATKW3VHMFG27DY' +const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}` + +let server: MockCtsServer +let savedHost: string | undefined + +beforeEach(async () => { + server = await MockCtsServer.start() + // OidcFederationStrategy reads the CTS base URL from CS_CTS_HOST at runtime, + // so set it before constructing the strategy. + savedHost = process.env.CS_CTS_HOST + process.env.CS_CTS_HOST = server.baseUrl +}) + +afterEach(async () => { + if (savedHost === undefined) { + delete process.env.CS_CTS_HOST + } else { + process.env.CS_CTS_HOST = savedHost + } + await server.close() +}) + +/** A `getJwt` callback that counts invocations and returns a fixed JWT. */ +function countingJwt() { + let calls = 0 + return { + calls: () => calls, + getJwt: () => { + calls += 1 + return Promise.resolve('header.payload.signature') + }, + } +} + +/** An in-memory `{ load, save }` token store dealing in JSON strings. */ +function memStore() { + let saved: string | null = null + return { + saved: () => saved, + load: () => Promise.resolve(saved), + save: (json: string) => { + saved = json + return Promise.resolve() + }, + } +} + +/** Unwrap a `create` Result, failing the test if it returned a failure. */ +function mustCreate( + ...args: Parameters<typeof OidcFederationStrategy.create> +): InstanceType<typeof OidcFederationStrategy> { + const cr = OidcFederationStrategy.create(...args) + if (cr.failure) { + expect.unreachable(`create failed: ${cr.failure.type}`) + } + return cr.data +} + +/** Unwrap a `createWithStore` Result, failing the test if it returned a failure. */ +function mustCreateWithStore( + ...args: Parameters<typeof OidcFederationStrategy.createWithStore> +): InstanceType<typeof OidcFederationStrategy> { + const cr = OidcFederationStrategy.createWithStore(...args) + if (cr.failure) { + expect.unreachable(`createWithStore failed: ${cr.failure.type}`) + } + return cr.data +} + +describe('OidcFederationStrategy (TypeScript / vitest)', () => { + it('federates a third-party JWT into a CTS service token', async () => { + server.mockAuthorizeEndpoint() + const jwt = countingJwt() + const strategy = mustCreate(WORKSPACE_CRN, jwt.getJwt) + + const r = await strategy.getToken() + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`) + } + const result = r.data + + expect(result.token).not.toBe('') + expect(result.workspaceId).toBe(WORKSPACE_ID) + expect(jwt.calls()).toBe(1) + }) + + it('surfaces WORKSPACE_MISMATCH with the expected/actual payload', async () => { + // The federated token carries a different workspace than the strategy's CRN, + // so workspace verification fails. This is the flagship structured-payload + // failure — it exercises the `...payload` spread end to end (the named + // help/url destructure doesn't), so it guards `failure.expected`/`.actual` + // against a serde key rename or a spread regression at the JS boundary. + const MISMATCHED_WORKSPACE = 'AAAAAAAAAAAAAAAA' + server.mockAuthorizeEndpointWithWorkspace(MISMATCHED_WORKSPACE) + const strategy = mustCreate(WORKSPACE_CRN, countingJwt().getJwt) + + const r = await strategy.getToken() + if (!r.failure) { + expect.unreachable('expected a WORKSPACE_MISMATCH failure') + } + const { failure } = r + if (failure.type !== 'WORKSPACE_MISMATCH') { + expect.unreachable(`expected WORKSPACE_MISMATCH, got ${failure.type}`) + } + expect(failure.expected).toBe(WORKSPACE_ID) + expect(failure.actual).toBe(MISMATCHED_WORKSPACE) + expect(failure.error).toBeInstanceOf(Error) + }) + + it('re-federates after the cached token expires', async () => { + // expiry 0 → the federated token is immediately expired, so the second + // getToken() must re-federate rather than serve a cached token. + server.mockAuthorizeEndpoint(0) + server.mockAuthorizeEndpoint(0) + const jwt = countingJwt() + const strategy = mustCreate(WORKSPACE_CRN, jwt.getJwt) + + await strategy.getToken() + await strategy.getToken() + + expect(jwt.calls()).toBe(2) + }) + + it('surfaces a getJwt rejection as a failure with .type', async () => { + server.mockAuthorizeEndpoint() + const strategy = mustCreate(WORKSPACE_CRN, () => + Promise.reject(new Error('provider unavailable')), + ) + + const r = await strategy.getToken() + expect(r.failure?.type).toBe('SERVER_ERROR') + }) + + it('honours an explicit baseUrl override over CS_CTS_HOST', async () => { + // CS_CTS_HOST (set in beforeEach) points at `server`, which here 500s on + // federation. A second server is the override target and succeeds. If the + // napi `baseUrl` arg is threaded through `maybe_base_url`, federation hits + // the override and resolves; if the override were dropped, it would hit + // CS_CTS_HOST's 500 and reject. This proves the precedence guarantee + // motivating CIP-3246 survives the napi parameter threading — the Rust core + // proves the ordering, this proves the binding preserves it. + server.mockAuthorizeEndpointError() + const override = await MockCtsServer.start() + try { + override.mockAuthorizeEndpoint() + const strategy = mustCreate( + WORKSPACE_CRN, + () => Promise.resolve('header.payload.signature'), + override.baseUrl, + ) + + const r = await strategy.getToken() + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`) + } + + expect(r.data.workspaceId).toBe(WORKSPACE_ID) + } finally { + await override.close() + } + }) + + it('honours a baseUrl override over CS_CTS_HOST for createWithStore', async () => { + // The store-variant twin of the precedence test: `baseUrl` is the 5th + // positional arg here (vs the 3rd on `create`), threaded through a separate + // wrapper path in index.js. CS_CTS_HOST's `server` 500s; the override + // server succeeds. getToken resolving (and the token landing in the store) + // proves the 5th-positional override is threaded, not dropped or + // mis-positioned. + server.mockAuthorizeEndpointError() + const override = await MockCtsServer.start() + try { + override.mockAuthorizeEndpoint() + const store = memStore() + const strategy = mustCreateWithStore( + WORKSPACE_CRN, + () => Promise.resolve('header.payload.signature'), + store.load, + store.save, + override.baseUrl, + ) + + const r = await strategy.getToken() + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`) + } + + expect(r.data.workspaceId).toBe(WORKSPACE_ID) + expect(store.saved()).not.toBeNull() + } finally { + await override.close() + } + }) + + it('rejects a malformed baseUrl with INVALID_URL', () => { + // The napi twin of the wasm `..._rejects_invalid_base_url` test: a + // non-empty, unparseable override must surface through the factory as a + // coded INVALID_URL failure (via `maybe_base_url(...)? → to_napi_error`), not + // a silent fallback or an un-coded throw. + const cr = OidcFederationStrategy.create( + WORKSPACE_CRN, + () => Promise.resolve('h.p.s'), + 'not a url', + ) + expect(cr.failure?.type).toBe('INVALID_URL') + }) + + it('treats an empty baseUrl as absent (falls back to CS_CTS_HOST)', async () => { + // An empty-string override must be a no-op, not an INVALID_URL — so + // federation still resolves against CS_CTS_HOST's mock. + server.mockAuthorizeEndpoint() + const strategy = mustCreate( + WORKSPACE_CRN, + () => Promise.resolve('header.payload.signature'), + '', + ) + + const r = await strategy.getToken() + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`) + } + + expect(r.data.workspaceId).toBe(WORKSPACE_ID) + }) + + it('rejects an invalid workspace CRN with .type', () => { + const cr = OidcFederationStrategy.create('not-a-crn', () => + Promise.resolve('h.p.s'), + ) + expect(cr.failure?.type).toBe('INVALID_CRN') + }) + + it('attaches diagnostic help to an INVALID_CRN failure', () => { + // INVALID_CRN carries `#[diagnostic(help(...))]`, so its envelope includes + // `help` — pins the `help !== undefined` branch of `toFailure` in index.js + // with a content assertion, not just presence: the text must survive the + // __CS_FAIL__ envelope round-trip intact. + const cr = OidcFederationStrategy.create('not-a-crn', () => + Promise.resolve('h.p.s'), + ) + expect(cr.failure?.type).toBe('INVALID_CRN') + expect(typeof cr.failure?.help).toBe('string') + expect(cr.failure?.help).toMatch(/crn:<region>:<workspace-id>/) + // The same help is mirrored onto the live Error for loggers that only + // see the error object. + expect( + (cr.failure?.error as (Error & { help?: string }) | undefined)?.help, + ).toBe(cr.failure?.help) + }) + + it('rejects a CRN whose workspace segment is malformed with .type', () => { + // "not-a-crn" above fails at the `crn:` prefix; this is the distinct path + // where the prefix/region parse but the workspace segment fails validation + // — what the old INVALID_WORKSPACE_ID case covered before the CRN switch. + const cr = OidcFederationStrategy.create( + 'crn:ap-southeast-2.aws:not-a-valid-workspace', + () => Promise.resolve('h.p.s'), + ) + expect(cr.failure?.type).toBe('INVALID_CRN') + }) + + it('persists the federated token to the store', async () => { + server.mockAuthorizeEndpoint() + const store = memStore() + const jwt = countingJwt() + const strategy = mustCreateWithStore( + WORKSPACE_CRN, + jwt.getJwt, + store.load, + store.save, + ) + + await strategy.getToken() + + expect(store.saved()).not.toBeNull() + expect(jwt.calls()).toBe(1) + }) + + it('loads a cached token from the store without re-federating', async () => { + // First strategy federates and populates the shared store. + server.mockAuthorizeEndpoint() + const store = memStore() + const first = mustCreateWithStore( + WORKSPACE_CRN, + () => Promise.resolve('h.p.s'), + store.load, + store.save, + ) + await first.getToken() + expect(store.saved()).not.toBeNull() + + // Second strategy shares the store. Federation would fail (500) and getJwt + // would throw — proving the token came from the store, not the network. + server.clearMocks() + server.mockAuthorizeEndpointError() + const second = mustCreateWithStore( + WORKSPACE_CRN, + () => Promise.reject(new Error('getJwt must not be called')), + store.load, + store.save, + ) + + const r = await second.getToken() + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`) + } + expect(r.data.workspaceId).toBe(WORKSPACE_ID) + }) + + it('re-federates when the stored token JSON is malformed', async () => { + // A corrupt cookie/store value must be treated as a cache miss (the + // `serde_json::from_str(..).ok()` → None branch), not panic — so federation + // runs fresh. A version that `unwrap()`ed the parse would fail this. + server.mockAuthorizeEndpoint() + const jwt = countingJwt() + const strategy = mustCreateWithStore( + WORKSPACE_CRN, + jwt.getJwt, + () => Promise.resolve('}{ not json'), + (_json: string) => Promise.resolve(), + ) + + await strategy.getToken() + + // Garbage cache discarded → exactly one fresh federation. + expect(jwt.calls()).toBe(1) + }) + + it('surfaces a non-string getJwt result as a failure with .type', async () => { + // Mirrors the wasm `js_oidc_provider_errors_on_non_string_result` test: + // a `Promise<number>` fails napi's `Promise<String>` coercion and must + // surface as a clean SERVER_ERROR failure, not a panic or hung promise. + server.mockAuthorizeEndpoint() + const strategy = mustCreate(WORKSPACE_CRN, () => + Promise.resolve(42 as unknown as string), + ) + + const r = await strategy.getToken() + expect(r.failure?.type).toBe('SERVER_ERROR') + }) + + it('surfaces a federation server error with .type', async () => { + // Negative twin of the happy path: a real federation request reaching + // /api/authorise and getting a 500 must surface a failure with a `.type`, + // not resolve or throw an un-coded error. + server.mockAuthorizeEndpointError() + const strategy = mustCreate(WORKSPACE_CRN, () => + Promise.resolve('header.payload.signature'), + ) + + const r = await strategy.getToken() + expect(r.failure).toBeTruthy() + expect(r.failure?.type).toBeTruthy() + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts new file mode 100644 index 000000000..a3cd438fa --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts @@ -0,0 +1,114 @@ +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { MockCtsServer } from './helpers/mock-cts-server' +import { saveTestToken } from './helpers/test-fixtures' + +const { bindClientDevice } = require('../index.js') as typeof import('../index') + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const TEST_WORKSPACE_ID = 'ZVATKW3VHMFG27DY' + +let server: MockCtsServer +let profileDir: string +let savedConfigPath: string | undefined + +function freshProfileDir(): string { + return mkdtempSync(join(tmpdir(), 'cs-auth-test-')) +} + +function workspaceDir(): string { + return join(profileDir, 'workspaces', TEST_WORKSPACE_ID) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('provision device client (TypeScript / vitest)', () => { + beforeEach(async () => { + server = await MockCtsServer.start() + profileDir = freshProfileDir() + // Production `bindClientDevice()` resolves its profile dir from + // CS_CONFIG_PATH (ProfileStore::resolve), so point it at the temp dir. + savedConfigPath = process.env.CS_CONFIG_PATH + process.env.CS_CONFIG_PATH = profileDir + }) + + afterEach(async () => { + if (savedConfigPath === undefined) { + delete process.env.CS_CONFIG_PATH + } else { + process.env.CS_CONFIG_PATH = savedConfigPath + } + await server.close() + }) + + it('creates secretkey.json on successful provisioning', async () => { + server.mockCreateClientEndpoint() + saveTestToken(profileDir, server.baseUrl) + + const r = await bindClientDevice() + if (r.failure) { + expect.unreachable(`bindClientDevice failed: ${r.failure.type}`) + } + + const raw = readFileSync(join(workspaceDir(), 'secretkey.json'), 'utf-8') + const secretKey = JSON.parse(raw) + expect(secretKey.client_id).toBe('00000000-0000-0000-0000-000000000001') + expect(secretKey.client_key).toBe('dGVzdC1rZXktbWF0ZXJpYWw=') + }) + + it('is a no-op when secretkey.json already exists', async () => { + // No mock endpoints needed — should short-circuit before any HTTP call. + saveTestToken(profileDir, server.baseUrl) + + // Pre-create secretkey.json in the workspace directory + const existing = JSON.stringify({ + client_id: 'existing-id', + client_key: 'existing-key', + }) + writeFileSync(join(workspaceDir(), 'secretkey.json'), existing) + + const r = await bindClientDevice() + if (r.failure) { + expect.unreachable(`bindClientDevice failed: ${r.failure.type}`) + } + + const raw = readFileSync(join(workspaceDir(), 'secretkey.json'), 'utf-8') + const secretKey = JSON.parse(raw) + expect(secretKey.client_id).toBe('existing-id') + }) + + it('is a no-op on 409 conflict', async () => { + server.mockCreateClientConflict() + saveTestToken(profileDir, server.baseUrl) + + const r = await bindClientDevice() + if (r.failure) { + expect.unreachable(`bindClientDevice failed: ${r.failure.type}`) + } + + expect(existsSync(join(workspaceDir(), 'secretkey.json'))).toBe(false) + }) + + it('fails on server error', async () => { + // No mock endpoint — server will return an error for unmatched route. + saveTestToken(profileDir, server.baseUrl) + + const r = await bindClientDevice() + expect(r.failure).toBeTruthy() + expect(r.failure?.error).toBeInstanceOf(Error) + }) + + it('fails with STORE_ERROR when auth token is missing', async () => { + // No token saved — should fail trying to load auth.json + const r = await bindClientDevice() + expect(r.failure?.error).toBeInstanceOf(Error) + expect(r.failure?.type).toBe('STORE_ERROR') + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts b/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts new file mode 100644 index 000000000..35c5fca6d --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts @@ -0,0 +1,63 @@ +import { existsSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' + +// JS-level coverage of `wasm-inline.mjs`'s `toFailure` — the seam that turns +// the wasm binding's branded `__authFailure` object into a `Result` failure. +// The Rust side of the brand is pinned by the wasm-bindgen test +// `to_js_error_attaches_auth_failure_object_with_payload_and_help`; this file +// pins the JS side: envelope fields surface on the failure, and the internal +// `message` field is stripped rather than leaking as a spread field. +// +// The wasm artifacts are gitignored (`npm run build:wasm` produces them), and +// the vitest CI job builds only the napi module — so these tests self-skip +// when the shim is absent. They run locally after a wasm build. +const WASM_SHIM = join(__dirname, '..', 'wasm', 'stack_auth_wasm_inline.js') + +describe.skipIf(!existsSync(WASM_SHIM))('wasm-inline Result wrapper', () => { + const VALID_CRN = 'crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY' + const VALID_KEY = 'CSAKtestKeyId.testKeySecret' + + async function loadWasmInline() { + // Dynamic import: a static one would fail module resolution when the + // gitignored artifacts are absent, even with the describe skipped. + return import('../wasm-inline.mjs') + } + + it('converts a branded wasm error into a typed failure with help', async () => { + const { AccessKeyStrategy } = await loadWasmInline() + const r = AccessKeyStrategy.create('not-a-crn', VALID_KEY) + expect(r.failure?.type).toBe('INVALID_CRN') + expect(r.failure?.error).toBeInstanceOf(Error) + // help from the serialized envelope must surface on the failure. + expect(r.failure?.help).toMatch(/crn:<region>:<workspace-id>/) + // ...and be mirrored onto the live Error, matching the napi seam, so loggers + // that only see `failure.error` still get the hint. + expect( + (r.failure?.error as (Error & { help?: string }) | undefined)?.help, + ).toBe(r.failure?.help) + }) + + it("strips the envelope's message field instead of spreading it", async () => { + const { AccessKeyStrategy } = await loadWasmInline() + const r = AccessKeyStrategy.create('not-a-crn', VALID_KEY) + if (!r.failure) { + expect.unreachable('create should fail for a malformed CRN') + } + // `message` rides in `__authFailure` for the Rust-side envelope tests but + // is internal here — the live Error already carries it. A regression in + // the `delete payload.message` line would spread it onto the failure. + expect('message' in r.failure).toBe(false) + expect(r.failure.error.message).toContain('Invalid workspace CRN') + }) + + it('returns { data } wrapping a usable strategy on success', async () => { + const { AccessKeyStrategy } = await loadWasmInline() + const r = AccessKeyStrategy.create(VALID_CRN, VALID_KEY) + if (r.failure) { + expect.unreachable(`create failed: ${r.failure.type}`) + } + expect(typeof r.data.getToken).toBe('function') + r.data.free() + }) +}) diff --git a/languages/typescript/packages/auth/base64url.d.ts b/languages/typescript/packages/auth/base64url.d.ts new file mode 100644 index 000000000..c26001008 --- /dev/null +++ b/languages/typescript/packages/auth/base64url.d.ts @@ -0,0 +1,13 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* + * Shared base64url codec used by both the `/cookies` token-cookie value and the + * `/next` warmed-token header. WHATWG-only (Edge-runtime safe). See base64url.mjs. + */ + +/** Encode a UTF-8 string as unpadded base64url. */ +export declare function encodeBase64Url(input: string): string; + +/** Decode an unpadded base64url string back to UTF-8. */ +export declare function decodeBase64Url(input: string): string; diff --git a/languages/typescript/packages/auth/base64url.mjs b/languages/typescript/packages/auth/base64url.mjs new file mode 100644 index 000000000..60604af7b --- /dev/null +++ b/languages/typescript/packages/auth/base64url.mjs @@ -0,0 +1,39 @@ +/* @ts-self-types="./base64url.d.ts" */ + +// Shared base64url codec for the token-cookie value (`/cookies`) and the warmed- +// token request header (`/next`). Both transports must encode/decode +// identically, so the implementation lives here rather than being copied into +// each. Uses only WHATWG `btoa`/`atob`/`TextEncoder`/`TextDecoder` — NOT Node's +// `Buffer` — because `/next` runs in the Edge middleware runtime where `Buffer` +// is not reliably available. + +/** + * @param {string} input + * @returns {string} + */ +export function encodeBase64Url(input) { + // btoa works on binary strings; encode the UTF-8 bytes first so non-ASCII + // round-trips. Token JSON is ASCII in practice but be defensive. + const bytes = new TextEncoder().encode(input); + let binary = ""; + for (let i = 0; i < bytes.length; i++) + binary += String.fromCharCode(bytes[i]); + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); +} + +/** + * @param {string} input + * @returns {string} + */ +export function decodeBase64Url(input) { + const padded = input.replaceAll("-", "+").replaceAll("_", "/"); + const pad = + padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4)); + const binary = atob(padded + pad); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder().decode(bytes); +} diff --git a/languages/typescript/packages/auth/build.rs b/languages/typescript/packages/auth/build.rs new file mode 100644 index 000000000..9fc236788 --- /dev/null +++ b/languages/typescript/packages/auth/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/languages/typescript/packages/auth/cookies.d.ts b/languages/typescript/packages/auth/cookies.d.ts new file mode 100644 index 000000000..42c3e07be --- /dev/null +++ b/languages/typescript/packages/auth/cookies.d.ts @@ -0,0 +1,76 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* + * Pluggable cookie-backed `TokenStore` for `@cipherstash/auth`. Works in + * any runtime that exposes WHATWG `Request` + `Headers`: Supabase Edge + * Functions, Cloudflare Workers, Bun, Deno, Node 18+, Next.js App Router. + * + * Pair with `AccessKeyStrategy.create(workspaceCrn, key, { store })` from + * the `/wasm-inline` entry (or, once the napi binding supports it, the + * main `.` entry). + */ + +import type { TokenStore } from "./wasm-inline.d.ts"; + +export type { TokenStore }; + +/** + * Configuration for {@link cookieStore}. + */ +export interface CookieStoreOptions { + /** Incoming request — the helper reads the `Cookie:` header off this. */ + request: Request; + /** Outgoing response headers — `Set-Cookie` is appended on every save. */ + responseHeaders: Headers; + /** Cookie name. Default: `"cs_token"`. */ + name?: string; + /** `Domain` attribute. Default: unset (host-only). */ + domain?: string; + /** `Path` attribute. Default: `"/"`. */ + path?: string; + /** `Secure` flag. Set `false` only for localhost HTTP dev. Default: `true`. */ + secure?: boolean; + /** `HttpOnly` flag — prevents JS access. Default: `true`. */ + httpOnly?: boolean; + /** + * `SameSite` attribute. Default: `"Lax"`. Passing `"None"` requires + * `secure: true` — `cookieStore` throws otherwise, since browsers drop + * non-Secure `SameSite=None` cookies. + */ + sameSite?: "Strict" | "Lax" | "None"; + /** + * Seconds subtracted from the token's `expires_at` when computing the + * cookie's `Max-Age`. Ensures the cookie expires slightly before the + * underlying token does, so loaded tokens stay usable. Default: `30`. + */ + expirySafetyMarginSeconds?: number; +} + +/** + * Build a {@link TokenStore} backed by an HTTP-only cookie. + * + * @example + * ```ts + * import { AccessKeyStrategy } from "@cipherstash/auth/wasm-inline"; + * import { cookieStore } from "@cipherstash/auth/cookies"; + * + * Deno.serve(async (req) => { + * const responseHeaders = new Headers(); + * const created = AccessKeyStrategy.create(workspaceCrn, accessKey, { + * store: cookieStore({ request: req, responseHeaders }), + * }); + * if (created.failure) { + * return new Response(created.failure.type, { status: 500, headers: responseHeaders }); + * } + * // Recommended: hand `created.data` to a CipherStash SDK (see the README). + * // Reading the token directly, as below, is a lower-level escape hatch. + * const result = await created.data.getToken(); + * if (result.failure) { + * return new Response(result.failure.type, { status: 500, headers: responseHeaders }); + * } + * return new Response(JSON.stringify(result.data), { headers: responseHeaders }); + * }); + * ``` + */ +export declare function cookieStore(options: CookieStoreOptions): TokenStore; diff --git a/languages/typescript/packages/auth/cookies.mjs b/languages/typescript/packages/auth/cookies.mjs new file mode 100644 index 000000000..61b37f594 --- /dev/null +++ b/languages/typescript/packages/auth/cookies.mjs @@ -0,0 +1,152 @@ +/* @ts-self-types="./cookies.d.ts" */ + +// Pluggable cookie-backed `TokenStore` for `@cipherstash/auth`. Works in any +// runtime that exposes WHATWG `Request` + `Headers`: Supabase Edge Functions, +// Cloudflare Workers, Bun, Deno, Node 18+, Next.js App Router. The strategy +// stays substrate-agnostic — same helper plugs into both the wasm +// `AccessKeyStrategy` (this package's `/wasm-inline` entry) and the future +// napi binding. +// +// Cookie value is base64url-encoded because the raw Token JSON contains `"` +// characters, which fall outside RFC 6265's allowed cookie-value char range +// and are rejected by spec-conformant cookie libraries (the `@std/http/cookie` +// failure that bit the supawasm spike on its first end-to-end test). + +import { decodeBase64Url, encodeBase64Url } from "./base64url.mjs"; + +const DEFAULT_NAME = "cs_token"; +const DEFAULT_PATH = "/"; +const DEFAULT_SAFETY_MARGIN_SECONDS = 30; + +/** + * @typedef {object} CookieStoreOptions + * @property {Request} request Incoming request to read the cookie from + * @property {Headers} responseHeaders Outgoing headers to append `Set-Cookie` to + * @property {string} [name="cs_token"] Cookie name + * @property {string} [domain] `Domain` attribute + * @property {string} [path="/"] `Path` attribute + * @property {boolean} [secure=true] `Secure` flag — set to `false` only for localhost HTTP dev + * @property {boolean} [httpOnly=true] `HttpOnly` flag + * @property {"Strict" | "Lax" | "None"} [sameSite="Lax"] `SameSite` attribute — `"None"` requires `secure: true` + * @property {number} [expirySafetyMarginSeconds=30] Seconds to subtract from token expiry when computing `Max-Age` + */ + +/** + * @param {CookieStoreOptions} options + * @returns {{ load(): Promise<string | null>; save(json: string): Promise<void> }} + */ +export function cookieStore(options) { + const { + request, + responseHeaders, + name = DEFAULT_NAME, + domain, + path = DEFAULT_PATH, + secure = true, + httpOnly = true, + sameSite = "Lax", + expirySafetyMarginSeconds = DEFAULT_SAFETY_MARGIN_SECONDS, + } = options; + + // Browsers reject `SameSite=None` cookies that aren't also `Secure`, so the + // cookie would silently fail to persist. Fail fast on the misconfiguration. + if (sameSite === "None" && !secure) { + throw new Error( + 'cookieStore: `sameSite: "None"` requires `secure: true` — browsers drop non-Secure SameSite=None cookies.', + ); + } + + return { + async load() { + const cookies = parseCookieHeader(request.headers.get("cookie")); + const encoded = cookies[name]; + if (!encoded) return null; + try { + return decodeBase64Url(encoded); + } catch { + return null; + } + }, + async save(json) { + const value = encodeBase64Url(json); + const maxAge = maxAgeFromTokenJson(json, expirySafetyMarginSeconds); + responseHeaders.append( + "set-cookie", + serializeSetCookie({ + name, + value, + domain, + path, + secure, + httpOnly, + sameSite, + maxAge, + }), + ); + }, + }; +} + +// --------------------------------------------------------------------------- +// Vendored cookie parse / serialise — RFC 6265, minimal subset we need +// --------------------------------------------------------------------------- + +/** + * @param {string | null} header + * @returns {Record<string, string>} + */ +function parseCookieHeader(header) { + /** @type {Record<string, string>} */ + const out = {}; + if (!header) return out; + for (const pair of header.split(";")) { + const eq = pair.indexOf("="); + if (eq < 0) continue; + const k = pair.slice(0, eq).trim(); + const v = pair.slice(eq + 1).trim(); + if (k && !(k in out)) out[k] = v; + } + return out; +} + +/** + * @param {{ + * name: string; + * value: string; + * domain?: string; + * path?: string; + * secure?: boolean; + * httpOnly?: boolean; + * sameSite?: "Strict" | "Lax" | "None"; + * maxAge?: number; + * }} opts + * @returns {string} + */ +function serializeSetCookie(opts) { + const parts = [`${opts.name}=${opts.value}`]; + if (opts.domain) parts.push(`Domain=${opts.domain}`); + if (opts.path) parts.push(`Path=${opts.path}`); + if (typeof opts.maxAge === "number") + parts.push(`Max-Age=${Math.floor(opts.maxAge)}`); + if (opts.httpOnly) parts.push("HttpOnly"); + if (opts.secure) parts.push("Secure"); + if (opts.sameSite) parts.push(`SameSite=${opts.sameSite}`); + return parts.join("; "); +} + +/** + * @param {string} json + * @param {number} safetyMarginSeconds + * @returns {number | undefined} + */ +function maxAgeFromTokenJson(json, safetyMarginSeconds) { + try { + const parsed = JSON.parse(json); + const expiresAt = parsed?.expires_at; + if (typeof expiresAt !== "number") return undefined; + const nowSeconds = Math.floor(Date.now() / 1000); + return Math.max(0, expiresAt - nowSeconds - safetyMarginSeconds); + } catch { + return undefined; + } +} diff --git a/languages/typescript/packages/auth/examples/auto-strategy.ts b/languages/typescript/packages/auth/examples/auto-strategy.ts new file mode 100644 index 000000000..002a0df5a --- /dev/null +++ b/languages/typescript/packages/auth/examples/auto-strategy.ts @@ -0,0 +1,55 @@ +// Example: Auto-detect credentials and retrieve a service token. +// +// `AutoStrategy` picks the best available authentication method: +// +// 1. Access key — if `CS_CLIENT_ACCESS_KEY` is set (along with +// `CS_WORKSPACE_CRN`), access key auth is used. +// 2. OAuth — if `~/.cipherstash/auth.json` exists (written by +// `stash login`), OAuth token auth is used. +// 3. If neither is available, a `NOT_AUTHENTICATED` failure is returned. +// +// Prerequisites: +// 1. Build the native module: npm run build +// +// Usage (after `stash login`): +// npx tsx examples/auto-strategy.ts +// +// Usage (with an access key): +// CS_CLIENT_ACCESS_KEY=<key> CS_WORKSPACE_CRN=<crn> npx tsx examples/auto-strategy.ts + +import type { AuthFailure } from '../index' +import { AutoStrategy } from '../index' + +function reportAndExit(failure: AuthFailure): never { + console.error(`[${failure.type}] ${failure.error.message}`) + if (failure.help) console.error(failure.help) + process.exit(1) +} + +async function main() { + // Detect credentials automatically from env vars / profile store. + // You can also pass explicit values: + // + // AutoStrategy.detect({ accessKey: "CSAK...", workspaceCrn: "crn:..." }) + // + const detected = AutoStrategy.detect() + if (detected.failure) reportAndExit(detected.failure) + + // Retrieve a token — refresh happens automatically when needed. + const result = await detected.data.getToken() + if (result.failure) reportAndExit(result.failure) + + const token = result.data + console.log(`Subject: ${token.subject}`) + console.log(`Workspace: ${token.workspaceId}`) + console.log(`Issuer: ${token.issuer}`) + console.log(`Services: ${JSON.stringify(token.services)}`) + console.log(`Token: ${token.token.slice(0, 20)}...`) +} + +// Domain errors are returned as `failure`, not thrown — only a genuine +// internal fault reaches here. +main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)) + process.exit(1) +}) diff --git a/languages/typescript/packages/auth/examples/device-code.ts b/languages/typescript/packages/auth/examples/device-code.ts new file mode 100644 index 000000000..af17c9787 --- /dev/null +++ b/languages/typescript/packages/auth/examples/device-code.ts @@ -0,0 +1,57 @@ +// Example: OAuth 2.0 Device Code flow via the @cipherstash/auth Node bindings. +// +// The token is saved automatically to ~/.cipherstash/auth.json and is never +// exposed to JavaScript. +// +// Prerequisites: +// 1. Build the native module: npm run build +// +// Usage: +// npx tsx examples/device-code.ts + +import type { AuthFailure } from '../index' +import { beginDeviceCodeFlow } from '../index' + +function reportAndExit(failure: AuthFailure): never { + console.error(`[${failure.type}] ${failure.error.message}`) + process.exit(1) +} + +async function main() { + // Step 1: Begin the device code flow + const begun = await beginDeviceCodeFlow('ap-southeast-2.aws', 'cli') + if (begun.failure) reportAndExit(begun.failure) + const pending = begun.data + + // Step 2: Show the user their code and verification URL + console.log(`Your code is: ${pending.userCode}`) + console.log(`Visit: ${pending.verificationUriComplete}`) + console.log(`Code expires in: ${pending.expiresIn}s`) + console.log() + + // Optionally open the browser automatically + const opened = pending.openInBrowser() + if (opened.failure) reportAndExit(opened.failure) + if (!opened.data) { + console.log('Could not open browser — please visit the URL above manually.') + } + + // Step 3: Poll until the user authorizes (or the code expires). + // The token is saved to ~/.cipherstash/auth.json automatically. + console.log('Waiting for authorization...') + const result = await pending.pollForToken() + if (result.failure) reportAndExit(result.failure) + const auth = result.data + + console.log() + console.log('Authenticated! Token saved to ~/.cipherstash/auth.json') + console.log(` Expires at: ${new Date(auth.expiresAt * 1000).toISOString()}`) + console.log(` Expires in: ${auth.expiresIn}s`) +} + +// Domain errors are returned as `failure`, not thrown — only a genuine +// internal fault reaches here. +main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)) + process.exit(1) +}) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts new file mode 100644 index 000000000..70f4d35e3 --- /dev/null +++ b/languages/typescript/packages/auth/index.d.ts @@ -0,0 +1,171 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* + * Hand-written — NOT regenerated by `napi build`. + * + * `napi build --dts native.d.ts` writes the raw generated bindings to + * `native.d.ts`. Those bindings *throw* on failure; this file presents the + * public contract instead: every fallible operation returns a + * `@byteslice/result` `Result<T, AuthFailure>` (`{ data }` on success, + * `{ failure }` on error), assembled by `index.js` from the structured error + * the Rust layer emits. Consumers write `if (result.failure) …` and never + * `try/catch` for domain errors. + * + * The plain success types (`TokenResult`, `AuthResult`, `AutoStrategyOptions`) + * are re-used from `native.d.ts`; the strategy classes/functions are + * re-declared here with `Result`-returning signatures. `AuthFailure` mirrors + * `AuthError` in `packages/stack-auth/src/error.rs`; the `stack-auth-node` + * drift test `ts_auth_failure_union_matches_error_codes` guards it. + */ + +import type { Result } from "@byteslice/result"; +import type { TokenResult, AuthResult, AutoStrategyOptions } from "./native"; + +export type { TokenResult, AuthResult, AutoStrategyOptions }; + +/** Fields present on every `AuthFailure`. */ +interface FailureBase { + /** The live `Error` thrown across the FFI boundary, with `.message`/`.code`. */ + error: Error; + /** Actionable diagnostic guidance, when the error carries it. */ + help?: string; + /** A URL with more detail, when the error carries it. */ + url?: string; +} + +/** + * A domain failure returned in the `failure` arm of a `Result`. Discriminated + * by `type`; narrow on it to access per-variant payload (e.g. + * `WORKSPACE_MISMATCH`'s `expected`/`actual`). + */ +export type AuthFailure = + | (FailureBase & { type: "REQUEST_ERROR" }) + | (FailureBase & { type: "ACCESS_DENIED" }) + | (FailureBase & { type: "EXPIRED_TOKEN" }) + | (FailureBase & { type: "INVALID_GRANT" }) + | (FailureBase & { type: "INVALID_CLIENT" }) + | (FailureBase & { type: "INVALID_URL" }) + | (FailureBase & { type: "INVALID_REGION" }) + | (FailureBase & { type: "INVALID_TOKEN" }) + | (FailureBase & { type: "USAGE_LIMIT_EXCEEDED" }) + | (FailureBase & { type: "ORG_NOT_PROVISIONED" }) + | (FailureBase & { type: "SERVER_ERROR" }) + | (FailureBase & { type: "NOT_AUTHENTICATED" }) + | (FailureBase & { type: "MISSING_WORKSPACE_CRN" }) + | (FailureBase & { type: "INVALID_ACCESS_KEY" }) + | (FailureBase & { type: "INVALID_CRN" }) + | (FailureBase & { type: "WORKSPACE_MISMATCH"; expected: string; actual: string }) + | (FailureBase & { type: "INVALID_WORKSPACE_ID" }) + | (FailureBase & { type: "ALREADY_CONSUMED" }) + | (FailureBase & { type: "INTERNAL_ERROR" }) + | (FailureBase & { type: "CUSTOM" }) + | (FailureBase & { type: "STORE_ERROR" }); + +/** The machine-readable discriminant carried by every {@link AuthFailure}. */ +export type AuthErrorCode = AuthFailure["type"]; + +/** + * An auth strategy that auto-detects credentials from environment variables + * and the local profile store. + */ +export declare class AutoStrategy { + /** Detect available credentials and return an `AutoStrategy`. */ + static detect( + options?: AutoStrategyOptions | undefined | null, + ): Result<AutoStrategy, AuthFailure>; + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise<Result<TokenResult, AuthFailure>>; +} + +/** + * An auth strategy that uses a static access key for service-to-service + * or CI/CD authentication. + */ +export declare class AccessKeyStrategy { + /** + * Create a new `AccessKeyStrategy` for the given workspace CRN and access key. + * The CRN format is `crn:<region>:<workspace-id>`. A workspace mismatch fails + * `getToken()` with `failure.type === "WORKSPACE_MISMATCH"`. + */ + static create( + workspaceCrn: string, + accessKey: string, + ): Result<AccessKeyStrategy, AuthFailure>; + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise<Result<TokenResult, AuthFailure>>; +} + +/** + * An auth strategy that uses OAuth refresh tokens persisted to disk + * (`~/.cipherstash/auth.json`). + */ +export declare class DeviceSessionStrategy { + /** Load credentials from the default profile store. */ + static fromProfile(): Result<DeviceSessionStrategy, AuthFailure>; + /** Retrieve a valid access token, refreshing as needed. */ + getToken(): Promise<Result<TokenResult, AuthFailure>>; +} + +/** + * An auth strategy that federates a third-party OIDC JWT (Clerk, Supabase, …) + * into a CipherStash CTS service token via `/api/authorise`. + */ +export declare class OidcFederationStrategy { + /** + * Create an `OidcFederationStrategy` for the given workspace CRN. `getJwt` is + * called on every federation and must resolve to the current third-party OIDC + * JWT. `baseUrl` pins the strategy to a specific CTS host. + */ + static create( + workspaceCrn: string, + getJwt: () => Promise<string> | string, + baseUrl?: string | undefined | null, + ): Result<OidcFederationStrategy, AuthFailure>; + /** + * Like `create` but persists the federated CTS token through `loadToken` / + * `saveToken` (e.g. an HTTP-only cookie) so it survives across requests. + */ + static createWithStore( + workspaceCrn: string, + getJwt: () => Promise<string> | string, + loadToken: () => Promise<string | null | undefined> | string | null | undefined, + saveToken: (json: string) => Promise<void> | void, + baseUrl?: string | undefined | null, + ): Result<OidcFederationStrategy, AuthFailure>; + /** Retrieve a valid CTS service token, federating or re-federating as needed. */ + getToken(): Promise<Result<TokenResult, AuthFailure>>; +} + +/** The pending state of an in-progress OAuth 2.0 Device Authorization flow. */ +export declare class DeviceCodeResult { + get userCode(): string; + get verificationUri(): string; + get verificationUriComplete(): string; + get expiresIn(): number; + /** + * Poll the auth server until the user completes authorization. **Consumes** + * the internal handle — a second call fails with + * `failure.type === "ALREADY_CONSUMED"`. + */ + pollForToken(): Promise<Result<AuthResult, AuthFailure>>; + /** Open the verification URI in the user's default browser. Non-consuming. */ + openInBrowser(): Result<boolean, AuthFailure>; +} + +/** Begin the OAuth 2.0 Device Authorization flow. */ +export declare function beginDeviceCodeFlow( + region: string, + clientId: string, +): Promise<Result<DeviceCodeResult, AuthFailure>>; + +/** Provision a device client in ZeroKMS after login. */ +export declare function bindClientDevice(): Promise<Result<void, AuthFailure>>; + +/** + * Deprecated alias for {@link DeviceSessionStrategy}, exported at runtime as + * `module.exports.OAuthStrategy = DeviceSessionStrategy`. + * + * @deprecated Renamed to `DeviceSessionStrategy`. + */ +export declare const OAuthStrategy: typeof DeviceSessionStrategy; diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js new file mode 100644 index 000000000..84878dae5 --- /dev/null +++ b/languages/typescript/packages/auth/index.js @@ -0,0 +1,183 @@ +// Wrapper that loads the native napi-rs module and converts its outcomes into +// the `@byteslice/result` shape: `{ data }` on success, `{ failure }` on a +// domain error. The Rust side never reaches the caller as a throw — every +// `AuthError` crosses the FFI boundary as a `__CS_FAIL__`-sentineled JSON blob +// in the rejection/throw, which we parse here into a typed `failure`. Only a +// genuine panic (no sentinel) propagates as a thrown exception. + +const native = require("./stack-auth-node.js"); + +// Must match `FAILURE_SENTINEL` in src/lib.rs. +const FAILURE_SENTINEL = "__CS_FAIL__"; + +/** + * Convert a thrown/rejected native error into a `Result` `failure`. + * + * Domain failures carry the sentinel + serialized `AuthError` + * (`{ type, message, help?, url?, ...payload }`); we reuse the thrown `Error` + * as the live `failure.error`, restoring its message and attaching the + * structured fields. Anything without the sentinel is a real bug/panic and is + * re-thrown unchanged. + */ +function toFailure(err) { + if (!(err instanceof Error) || !err.message.startsWith(FAILURE_SENTINEL)) { + throw err; + } + let envelope; + try { + envelope = JSON.parse(err.message.slice(FAILURE_SENTINEL.length)); + } catch { + // Sentinel present but the tail isn't valid JSON. Can't happen with the + // current Rust producer (always emits valid JSON), but if it ever did, + // don't mask the real failure as an opaque SyntaxError — re-throw it. + throw err; + } + const { type, message, help, url, ...payload } = envelope; + err.message = message; + err.code = type; + // Spread payload first so the fixed `type`/`error` keys — and the `help`/`url` + // re-asserted below — always win over a colliding payload key. + const failure = { ...payload, type, error: err }; + if (help !== undefined) { + err.help = help; + failure.help = help; + } + if (url !== undefined) { + err.url = url; + failure.url = url; + } + return { failure }; +} + +/** + * Wrap an async native function so it resolves to `{ data }` / `{ failure }` + * and never rejects for a domain error. + */ +function wrapAsync(fn) { + return function (...args) { + // napi argument coercion throws synchronously, before a Promise exists — + // surface it as a rejection so a `Promise`-returning signature never + // throws. (Coercion errors carry no sentinel, so they stay errors.) + try { + return fn.apply(this, args).then((data) => ({ data }), toFailure); + } catch (err) { + return Promise.reject(err); + } + }; +} + +/** + * Wrap a sync native function so it returns `{ data }` / `{ failure }`. + */ +function wrapSync(fn) { + return function (...args) { + try { + return { data: fn.apply(this, args) }; + } catch (err) { + return toFailure(err); + } + }; +} + +// Patch DeviceCodeResult prototype methods +const dcProto = native.DeviceCodeResult.prototype; +dcProto.pollForToken = wrapAsync(dcProto.pollForToken); +dcProto.openInBrowser = wrapSync(dcProto.openInBrowser); + +// Patch strategy getToken methods +for (const Strategy of [ + native.AutoStrategy, + native.AccessKeyStrategy, + native.DeviceSessionStrategy, + native.OidcFederationStrategy, +]) { + Strategy.prototype.getToken = wrapAsync(Strategy.prototype.getToken); +} + +// napi defines class static methods as non-writable (and this file is sloppy +// mode), so the factories can't be Result-wrapped by patching the native +// class in place — the assignment silently no-ops. Each strategy instead gets +// a thin facade class whose static factories run through `wrapSync`. +// Instances are the native ones — their async `getToken()` is already +// wrapped via the prototype patch above. (Facade instances are never +// constructed, so `instanceof` against these classes is not part of the +// contract.) +// The native factory must be invoked as a method of its native class — +// napi needs the class as the receiver to construct the returned instance — +// hence the closure form rather than passing the unbound static to wrapSync. +class AutoStrategy { + static detect(options) { + return wrapSync(() => native.AutoStrategy.detect(options))(); + } +} + +class AccessKeyStrategy { + static create(workspaceCrn, accessKey) { + return wrapSync(() => + native.AccessKeyStrategy.create(workspaceCrn, accessKey), + )(); + } +} + +class DeviceSessionStrategy { + static fromProfile() { + return wrapSync(() => native.DeviceSessionStrategy.fromProfile())(); + } +} + +const NativeOidcFederationStrategy = native.OidcFederationStrategy; +class OidcFederationStrategy { + static create(workspaceCrn, getJwt, baseUrl) { + // Wrap `getJwt` so the napi binding always sees a Promise-returning + // function even if the caller passed a sync one — the native side coerces + // the return to `Promise<string>`. Matches the wasm wrapper (wasm-inline.mjs). + const jwt = () => Promise.resolve(getJwt()); + return wrapSync(() => + NativeOidcFederationStrategy.create(workspaceCrn, jwt, baseUrl), + )(); + } + + static createWithStore(workspaceCrn, getJwt, loadToken, saveToken, baseUrl) { + // Same defensive wrap for all three callbacks, so sync implementations + // (e.g. an in-memory store) work without the caller pre-wrapping them. + const jwt = () => Promise.resolve(getJwt()); + const load = () => Promise.resolve(loadToken()); + const save = (json) => Promise.resolve(saveToken(json)); + return wrapSync(() => + NativeOidcFederationStrategy.createWithStore( + workspaceCrn, + jwt, + load, + save, + baseUrl, + ), + )(); + } +} + +// Export wrapped top-level functions alongside native re-exports. The facade +// classes shadow their native counterparts from the `...native` spread. +module.exports = { + ...native, + AutoStrategy, + AccessKeyStrategy, + DeviceSessionStrategy, + OidcFederationStrategy, + // Deprecated alias: `OAuthStrategy` was renamed to `DeviceSessionStrategy`. + // Kept so existing consumers don't break; remove in a future major. + OAuthStrategy: DeviceSessionStrategy, + beginDeviceCodeFlow: wrapAsync(native.beginDeviceCodeFlow), + bindClientDevice: wrapAsync(native.bindClientDevice), +}; + +if (native.beginDeviceCodeFlowWithBaseUrl) { + module.exports.beginDeviceCodeFlowWithBaseUrl = wrapAsync( + native.beginDeviceCodeFlowWithBaseUrl, + ); +} + +if (native.bindClientDeviceWithProfileDir) { + module.exports.bindClientDeviceWithProfileDir = wrapAsync( + native.bindClientDeviceWithProfileDir, + ); +} diff --git a/languages/typescript/packages/auth/native.d.ts b/languages/typescript/packages/auth/native.d.ts new file mode 100644 index 000000000..0508eb3e2 --- /dev/null +++ b/languages/typescript/packages/auth/native.d.ts @@ -0,0 +1,165 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +/** + * The result of a successful `getToken()` call. + * + * Contains the bearer credential and decoded JWT claims for service discovery. + */ +export interface TokenResult { + /** The bearer token string (used as `Authorization: Bearer <token>`). */ + token: string + /** The subject claim from the JWT (e.g. `"CS|auth0|user123"` or `"CS|CSAKkeyId"`). */ + subject: string + /** The workspace identifier from the JWT. */ + workspaceId: string + /** The issuer URL from the JWT `iss` claim (i.e. the CTS host). */ + issuer: string + /** Service endpoint URLs from the JWT `services` claim (e.g. `{ zerokms: "https://..." }`). */ + services: Record<string, string> +} +/** Options for `AutoStrategy.detect()`. */ +export interface AutoStrategyOptions { + /** An explicit access key (takes precedence over `CS_CLIENT_ACCESS_KEY` env var). */ + accessKey?: string + /** An explicit workspace CRN (takes precedence over `CS_WORKSPACE_CRN` env var). */ + workspaceCrn?: string +} +/** + * Metadata returned after a successful device code authentication. + * + * The actual token is never exposed to JavaScript — it is saved directly + * to `~/.cipherstash/auth.json` by the Rust layer. + */ +export interface AuthResult { + /** Absolute epoch timestamp (seconds) when the token expires. */ + expiresAt: number + /** Number of seconds before the token expires (computed at time of return). */ + expiresIn: number +} +/** + * Provision a device client in ZeroKMS after login. + * + * Loads the auth token and device identity from `~/.cipherstash/`, + * creates a client on the workspace's default keyset, and persists the + * resulting secret key to `~/.cipherstash/secretkey.json`. + * + * This is a no-op if the secret key already exists or the server returns + * 409 (conflict). + */ +export declare function bindClientDevice(): Promise<void> +/** Begin the OAuth 2.0 Device Authorization flow. */ +export declare function beginDeviceCodeFlow(region: string, clientId: string): Promise<DeviceCodeResult> +/** + * An auth strategy that auto-detects credentials from environment variables + * and the local profile store. + * + * Detection order: + * 1. `CS_CLIENT_ACCESS_KEY` env var (or explicit `accessKey` option) → access key auth + * 2. `~/.cipherstash/auth.json` → OAuth token auth + * 3. Error: not authenticated + */ +export declare class AutoStrategy { + /** + * Detect available credentials and return an `AutoStrategy`. + * + * Pass options to provide explicit values that take precedence over + * environment variables. + */ + static detect(options?: AutoStrategyOptions | undefined | null): AutoStrategy + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise<TokenResult> +} +/** + * An auth strategy that uses a static access key for service-to-service + * or CI/CD authentication. + */ +export declare class AccessKeyStrategy { + /** + * Create a new `AccessKeyStrategy` for the given workspace CRN and + * access key. + * + * The CRN format is `crn:<region>:<workspace-id>` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed + * from the CRN and used for service discovery; the workspace ID is + * used to verify every issued token belongs to the right workspace. + * A mismatch fails `getToken()` with `code === "WORKSPACE_MISMATCH"`. + */ + static create(workspaceCrn: string, accessKey: string): AccessKeyStrategy + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise<TokenResult> +} +/** + * An auth strategy that uses OAuth refresh tokens persisted to disk + * (`~/.cipherstash/auth.json`). + */ +export declare class DeviceSessionStrategy { + /** Load credentials from the default profile store and create a `DeviceSessionStrategy`. */ + static fromProfile(): DeviceSessionStrategy + /** Retrieve a valid access token, refreshing as needed. */ + getToken(): Promise<TokenResult> +} +/** + * An auth strategy that federates a third-party OIDC JWT (Clerk, Supabase, …) + * into a CipherStash CTS service token via `/api/authorise`. + */ +export declare class OidcFederationStrategy { + /** + * Create an `OidcFederationStrategy` for the given workspace CRN. + * + * The CRN format is `crn:<region>:<workspace-id>` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from + * the CRN and used for service discovery; the workspace ID is used to + * verify every federated token belongs to the right workspace. + * + * `getJwt` is called on every federation — initial auth and every + * re-federation after the CTS token expires — and must return + * `Promise<string>` resolving to the *current* third-party OIDC JWT. + * + * `baseUrl`, when supplied, pins this strategy to a specific CTS host — + * e.g. a self-hosted CTS or a local mock auth server. It takes precedence + * over the `CS_CTS_HOST` environment variable and region service + * discovery, and is scoped to this strategy alone (unlike `CS_CTS_HOST`, + * which redirects every CTS client in the process). + */ + static create(workspaceCrn: string, getJwt: () => any, baseUrl?: string | undefined | null): OidcFederationStrategy + /** + * Create an `OidcFederationStrategy` backed by external token-store callbacks. + * + * Behaves like `create` but persists the federated CTS + * token through `loadToken` (`() => Promise<string | null | undefined>`) + * and `saveToken` (`(json: string) => Promise<void>`) — e.g. an HTTP-only + * cookie — so a federated token survives across requests without + * re-federating. + * + * `baseUrl` behaves as in `create` — an explicit, + * strategy-scoped CTS host that overrides `CS_CTS_HOST` and service + * discovery. + */ + static createWithStore(workspaceCrn: string, getJwt: () => any, loadToken: () => any, saveToken: (arg: string) => any, baseUrl?: string | undefined | null): OidcFederationStrategy + /** Retrieve a valid CTS service token, federating or re-federating as needed. */ + getToken(): Promise<TokenResult> +} +export declare class DeviceCodeResult { + get userCode(): string + get verificationUri(): string + get verificationUriComplete(): string + get expiresIn(): number + /** + * Poll the auth server until the user completes authorization. + * + * **Consumes** the internal handle — it cannot be reused after this call. + * If you need to open the browser, call `openInBrowser` *before* + * `pollForToken`. + */ + pollForToken(): Promise<AuthResult> + /** + * Open the verification URI in the user's default browser. + * + * Does **not** consume the handle — you can still call `pollForToken` + * afterwards. + */ + openInBrowser(): boolean +} diff --git a/languages/typescript/packages/auth/next.d.ts b/languages/typescript/packages/auth/next.d.ts new file mode 100644 index 000000000..f59ae3133 --- /dev/null +++ b/languages/typescript/packages/auth/next.d.ts @@ -0,0 +1,228 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* + * Public TS surface for the `/next` entry — a runtime adapter for federated CTS + * tokens in request/response server frameworks (built for the Next.js App + * Router, framework-agnostic by construction). See `next.mjs` for the model. + */ + +import type { TokenResult } from "./wasm-types.d.ts"; +import type { GetTokenResult } from "./wasm-inline.d.ts"; + +export type { TokenResult } from "./wasm-types.d.ts"; + +/** Request header carrying the warmed token from middleware to the render. */ +export declare const CS_TOKEN_HEADER: "x-cs-cts-token"; + +/** Per-workspace cookie name for the cached CTS token (`cs_token_<workspaceId>`). */ +export declare function csTokenCookieName(workspaceId: string): string; + +export interface CsFederateOptions { + /** Incoming request — the token cookie is read from its `Cookie` header. */ + request: Request; + /** Outgoing headers — the refreshed token cookie is appended as `Set-Cookie`. */ + responseHeaders: Headers; + /** Workspace CRN, `crn:<region>:<workspace-id>`. */ + workspaceCrn: string; + /** Mints the current third-party OIDC JWT (re-invoked on every re-federation). */ + getJwt: () => string | Promise<string>; + /** Pin federation to a specific CTS host / mock, overriding region discovery. */ + baseUrl?: string; + /** + * Cookie name. Defaults to `cs_token_<workspace-id>` derived from + * `workspaceCrn` (per-workspace cache). Only set this to override that name. + */ + cookieName?: string; + /** Cookie `Secure` flag — set `false` only for localhost HTTP dev. Default `true`. */ + secure?: boolean; + /** Cookie `SameSite`. Default `"Lax"`. */ + sameSite?: "Strict" | "Lax" | "None"; +} + +/** + * Federate-or-reuse a CTS service token, persisting it to the cookie. Use in any + * writable, in-scope context (middleware, route handler, server action). Returns + * a {@link TokenResult}; throws on failure. When you also need to warm the + * same-request render, prefer {@link csFederationMiddleware}. + * + * @example + * ```ts + * // app/api/data/route.ts — federate-or-reuse directly in a Route Handler. + * import { csFederate } from "@cipherstash/auth/next"; + * + * export async function GET(request: Request) { + * const responseHeaders = new Headers(); // the refreshed cookie is appended here + * const token = await csFederate({ + * request, + * responseHeaders, + * workspaceCrn: process.env.CS_WORKSPACE_CRN!, // "crn:<region>:<workspace-id>" + * getJwt: () => getSessionJwt(), // your provider's *current* JWT + * }); + * return Response.json({ workspaceId: token.workspaceId }, { headers: responseHeaders }); + * } + * ``` + */ +export declare function csFederate(options: CsFederateOptions): Promise<TokenResult>; + +export interface CsFederationMiddlewareOptions extends CsFederateOptions { + /** Request header to carry the warmed token. Default {@link CS_TOKEN_HEADER}. */ + headerName?: string; +} + +export interface CsFederationMiddlewareResult { + /** The federated token. */ + result: TokenResult; + /** + * Request headers to forward: a clone of the incoming headers with any + * client-supplied warmed-token header **stripped** (see + * {@link csSanitizeHeaders}) and the freshly minted one set. + */ + requestHeaders: Headers; + /** Request header to forward (default {@link CS_TOKEN_HEADER}). */ + headerName: string; + /** Encoded warmed-token payload to set on that header. */ + headerValue: string; +} + +/** + * Federate-or-reuse in middleware, returning the request headers that deliver + * the warmed token to the same-request render (a `Set-Cookie` written now is not + * readable in the same request). Forward `requestHeaders` via + * `NextResponse.next({ request: { headers: requestHeaders } })`; copy + * `responseHeaders` (carrying `Set-Cookie`) onto the response. Read it back with + * {@link csAuthHeader}. + * + * `requestHeaders` has the inbound client-supplied warmed-token header stripped + * ({@link csSanitizeHeaders}) before the minted one is set, so the forgery + * invariant lives in the library rather than in the caller's wiring. + * + * **Throws on federation failure** — including the ordinary signed-out case + * (no JWT to federate). Wrap it, or an unauthenticated request 500s in + * middleware; the example below shows the signed-out fallback. + * + * @example + * ```ts + * // middleware.ts — federate once per request, warm the render, refresh the cookie. + * import { NextResponse } from "next/server"; + * import { csFederationMiddleware, csSanitizeHeaders } from "@cipherstash/auth/next"; + * + * export async function middleware(request: Request) { + * const responseHeaders = new Headers(); + * + * let requestHeaders: Headers; + * try { + * ({ requestHeaders } = await csFederationMiddleware({ + * request, + * responseHeaders, + * workspaceCrn: process.env.CS_WORKSPACE_CRN!, + * getJwt: () => getSessionJwt(), // your provider's *current* JWT (Clerk, Supabase, …) + * })); + * } catch { + * // Signed out, or federation failed — let the request through unwarmed. + * // `csAuthHeader` returns null downstream, so the render falls back. + * // Still strip the header: an un-stripped inbound one would be forgeable. + * requestHeaders = csSanitizeHeaders(request); + * } + * + * // Deliver the warmed token (if any) to this request's render... + * const response = NextResponse.next({ request: { headers: requestHeaders } }); + * + * // ...and copy the refreshed `Set-Cookie` onto the response. + * responseHeaders.forEach((value, key) => response.headers.append(key, value)); + * return response; + * } + * ``` + */ +export declare function csFederationMiddleware( + options: CsFederationMiddlewareOptions, +): Promise<CsFederationMiddlewareResult>; + +export interface CsAuthHeaderOptions { + /** Header to read. Default {@link CS_TOKEN_HEADER}. */ + headerName?: string; +} + +/** + * Clone a request's headers with the warmed-token header removed, ready to + * forward to the render. + * + * SECURITY: this is the ingress strip that makes {@link csAuthHeader} + * trustworthy. Any path on which a client-supplied {@link CS_TOKEN_HEADER} + * survives to the render is a forgery hole, and Next.js middleware `matcher`s + * routinely exclude paths — so run every forwarded request through this + * (directly, or via {@link csFederationMiddleware}, which applies it), including + * on the signed-out and federation-error paths. + */ +export declare function csSanitizeHeaders( + source: Request | Headers, + options?: CsAuthHeaderOptions, +): Headers; + +/** + * An `AuthStrategy` backed by a token a middleware already warmed — it requires + * no federation, so consumers (incl. protect-ffi) can drive `getToken()` from + * any context. `getToken()` returns the same `Result` shape as a real strategy + * (always a `{ data }` success here, since the warmed token is pre-validated), + * so this stays a drop-in wherever an `OidcFederationStrategy` is consumed. + * + * **It does not refresh.** Unlike `OidcFederationStrategy` / `AccessKeyStrategy`, + * `getToken()` hands back the *same* token every time, so the strategy is valid + * only until that token's TTL expires — after which downstream CTS/ZeroKMS calls + * fail with no refresh path. Treat it as request-scoped: a detached callback + * within the request is fine, but don't cache it across requests — re-read the + * header (or federate) on the next one. + */ +export interface WarmedAuthStrategy { + readonly requiresFederation: false; + getToken(): Promise<GetTokenResult>; + free(): void; +} + +/** + * Read the warmed token injected by {@link csFederationMiddleware} into an + * `AuthStrategy`. The header is read EAGERLY and closed over, so the strategy is + * safe to drive from a detached callback. `headers` is anything with a + * `get(name)` method (WHATWG `Headers` or Next's `headers()`). Returns `null` + * when no warmed token is present, so the caller can fall back to a cold + * federation. + * + * The returned strategy does not refresh — see {@link WarmedAuthStrategy} for + * the TTL boundary. + * + * SECURITY: the header payload is opaque base64url(JSON), NOT authenticated — + * only trust it where the inbound client-supplied header is stripped on ingress + * ({@link csSanitizeHeaders}, which {@link csFederationMiddleware} applies for + * you), on *every* path this is reachable from. A forged payload carries an + * arbitrary `services` map, so it can point the app at an attacker-controlled + * ZeroKMS endpoint — an exfiltration vector, not just identity confusion. + * AEAD-sealing the payload is tracked in CIP-3112. + * + * @example + * ```ts + * // A Server Component / Route Handler reading the token the middleware warmed. + * import { headers } from "next/headers"; + * import { csAuthHeader } from "@cipherstash/auth/next"; + * import { Encryption } from "@cipherstash/stack"; + * + * export async function loadSecret() { + * const strategy = csAuthHeader(await headers()); + * if (!strategy) throw new Error("no warmed token — signed out, or middleware didn't run"); + * + * // Hand the strategy to a CipherStash SDK — it calls getToken() as needed. + * // (The warmed strategy itself never refreshes; it's good for this request.) + * const encryption = new Encryption({ authStrategy: strategy }); + * // ... encrypt / decrypt with `encryption` ... + * } + * ``` + */ +export declare function csAuthHeader( + headers: { get(name: string): string | null }, + options?: CsAuthHeaderOptions, +): WarmedAuthStrategy | null; + +/** Encode a {@link TokenResult} into the opaque header payload. */ +export declare function encodeTokenHeader(result: TokenResult): string; + +/** Decode the header payload produced by {@link encodeTokenHeader}. */ +export declare function decodeTokenHeader(value: string): TokenResult; diff --git a/languages/typescript/packages/auth/next.mjs b/languages/typescript/packages/auth/next.mjs new file mode 100644 index 000000000..ec00f41b6 --- /dev/null +++ b/languages/typescript/packages/auth/next.mjs @@ -0,0 +1,275 @@ +/* @ts-self-types="./next.d.ts" */ + +// Runtime adapter for federated CTS tokens in request/response server +// frameworks — built for the Next.js App Router but framework-agnostic by +// construction: every function operates on WHATWG `Request` / `Headers` and +// returns plain data, so the caller does the (tiny) framework wiring +// (`NextResponse.next({ request: { headers } })`, `headers()`), and the helpers +// stay unit-testable without Next installed. Mirrors the philosophy of the +// `/cookies` helper. +// +// The model: federate-or-reuse where the request is BOTH in-scope AND can write +// cookies (middleware / route handlers / server actions), persist to a per- +// workspace HTTP-only cookie for the cross-request cache, and hand the freshly +// minted token to the same-request render via a request header — because a +// `Set-Cookie` written now is not readable in the same request. + +import { decodeBase64Url, encodeBase64Url } from "./base64url.mjs"; +import { cookieStore } from "./cookies.mjs"; +import { OidcFederationStrategy } from "./wasm-inline.mjs"; + +/** Request header carrying the warmed token from middleware to the render. */ +export const CS_TOKEN_HEADER = "x-cs-cts-token"; + +/** Per-workspace cookie name for the cached CTS token. */ +export function csTokenCookieName(workspaceId) { + return `cs_token_${workspaceId}`; +} + +/** + * @typedef {object} CsFederateOptions + * @property {Request} request Incoming request (reads the token cookie) + * @property {Headers} responseHeaders Outgoing headers (the refreshed cookie is appended here) + * @property {string} workspaceCrn `crn:<region>:<workspace-id>` + * @property {() => string | Promise<string>} getJwt Mints the current third-party OIDC JWT (Clerk, …) + * @property {string} [baseUrl] Pin federation to a CTS host / mock (overrides region discovery) + * @property {string} [cookieName] Override the cookie name (defaults to `cs_token_<workspaceId>`, derived from `workspaceCrn`) + * @property {boolean} [secure=true] Cookie `Secure` flag — set `false` only for localhost HTTP dev + * @property {"Strict" | "Lax" | "None"} [sameSite="Lax"] Cookie `SameSite` + */ + +/** + * Federate-or-reuse a CTS service token, persisting the result to the cookie. + * Use in any writable, in-scope context (middleware, route handler, server + * action). Returns the `TokenResult` (`{ token, services, workspaceId, … }`). + * + * @param {CsFederateOptions} options + * @returns {Promise<import("./wasm-types.d.ts").TokenResult>} + */ +export async function csFederate(options) { + const { + request, + responseHeaders, + workspaceCrn, + getJwt, + baseUrl, + cookieName, + secure, + sameSite, + } = options; + + // Default to the per-workspace cookie name derived from the CRN + // (`crn:<region>:<workspace-id>`). Without this, omitting `cookieName` falls + // back to the cookieStore default (`cs_token`), collapsing every workspace's + // token into one cookie and causing cross-workspace cache collisions. + const workspaceId = workspaceCrn.split(":").at(-1); + const store = cookieStore({ + request, + responseHeaders, + name: + cookieName ?? (workspaceId ? csTokenCookieName(workspaceId) : undefined), + secure, + sameSite, + }); + // `create()` and `getToken()` return a `@byteslice/result` Result + // (`{ data }` on success, `{ failure }` on error) rather than throwing. Unwrap + // both and throw the live `failure.error`, keeping this helper's documented + // bare-`TokenResult`/throw-on-failure contract. `create()` runs outside the + // `try` so `free()` only fires once a strategy was actually allocated. + const created = OidcFederationStrategy.create(workspaceCrn, getJwt, { + store, + baseUrl, + }); + if (created.failure) throw created.failure.error; + const strategy = created.data; + try { + const result = await strategy.getToken(); + if (result.failure) throw result.failure.error; + return result.data; + } finally { + strategy.free(); + } +} + +/** + * @typedef {CsFederateOptions & { headerName?: string }} CsFederationMiddlewareOptions + */ + +/** + * @typedef {object} CsFederationMiddlewareResult + * @property {import("./wasm-types.d.ts").TokenResult} result The federated token + * @property {Headers} requestHeaders Sanitised request headers carrying the warmed token — forward these + * @property {string} headerName Request header to forward (default {@link CS_TOKEN_HEADER}) + * @property {string} headerValue Encoded warmed-token payload to set on that header + */ +/** + * Federate-or-reuse in middleware, then return the request headers that deliver + * the warmed token to the same-request render (a fresh `Set-Cookie` is not + * readable in the same request). The caller forwards `requestHeaders` via + * `NextResponse.next({ request: { headers: requestHeaders } })` and copies + * `responseHeaders` (carrying `Set-Cookie`) onto the response. + * + * `requestHeaders` is built with {@link csSanitizeHeaders}, so the inbound + * client-supplied warmed-token header is DELETED before the freshly minted one + * is set — the library owns that invariant rather than trusting the caller to + * overwrite it. On the signed-out / federation-failure path (where this function + * throws and no warmed token exists), call {@link csSanitizeHeaders} directly so + * the strip still happens. + * + * @param {CsFederationMiddlewareOptions} options + * @returns {Promise<CsFederationMiddlewareResult>} + */ +export async function csFederationMiddleware(options) { + const result = await csFederate(options); + const headerName = options.headerName ?? CS_TOKEN_HEADER; + const headerValue = encodeTokenHeader(result); + const requestHeaders = csSanitizeHeaders(options.request, { headerName }); + requestHeaders.set(headerName, headerValue); + return { result, requestHeaders, headerName, headerValue }; +} + +/** + * Clone a request's headers with the warmed-token header REMOVED, ready to + * forward to the render. + * + * SECURITY: this is the ingress strip that makes {@link csAuthHeader} + * trustworthy. Because the warmed-token payload is unauthenticated (see + * `csAuthHeader`), any request path on which a client-supplied + * {@link CS_TOKEN_HEADER} survives to the render is a forgery hole — and Next.js + * middleware `matcher`s routinely exclude paths. Run every forwarded request + * through this (directly, or via {@link csFederationMiddleware}, which calls it + * for you), including on the signed-out and federation-error paths. + * + * @param {Request | Headers} source Incoming request (or its headers) + * @param {CsAuthHeaderOptions} [options] `headerName` to strip (default {@link CS_TOKEN_HEADER}) + * @returns {Headers} + */ +export function csSanitizeHeaders(source, options) { + const headerName = options?.headerName ?? CS_TOKEN_HEADER; + const headers = new Headers( + source instanceof Headers ? source : source.headers, + ); + headers.delete(headerName); + return headers; +} + +/** + * @typedef {object} CsAuthHeaderOptions + * @property {string} [headerName] Header to read (default {@link CS_TOKEN_HEADER}) + */ + +/** + * @typedef {object} WarmedAuthStrategy + * @property {false} requiresFederation + * @property {() => Promise<import("./wasm-inline.d.ts").GetTokenResult>} getToken + * @property {() => void} free + */ + +/** + * Read the warmed token a middleware injected (via {@link csFederationMiddleware}) + * into an `AuthStrategy` whose `getToken()` resolves it. The header is read + * EAGERLY and the value closed over, so the returned strategy can be driven + * from a detached callback (e.g. protect-ffi) where request scope is gone. + * + * `headers` is anything with a `get(name)` method (a WHATWG `Headers`, or + * Next's `headers()` result). Returns `null` when no warmed token is present, + * so callers can fall back to a cold federation. + * + * LIFETIME: unlike `OidcFederationStrategy` / `AccessKeyStrategy`, this strategy + * does NOT refresh. `getToken()` returns the same closed-over token on every + * call, so it is only valid until that token's TTL expires — after which + * downstream CTS/ZeroKMS calls start failing with no refresh path. It is scoped + * to the request that warmed it: hold it no longer than the request (a detached + * callback is fine *within* the request), and re-read the header on the next one + * rather than caching the strategy across requests. + * + * SECURITY: the header payload is opaque base64url JSON, NOT authenticated. The + * `isTokenResult` guard only rejects malformed *shape*, not a forged-but-valid + * payload — and the shape it accepts includes an arbitrary `services` map, so a + * forged header can also point the app at an attacker-controlled ZeroKMS + * endpoint. The threat is therefore data/key EXFILTRATION, not just identity + * confusion. Only trust this where the inbound client-supplied header is + * stripped before the request reaches here — use {@link csSanitizeHeaders} (or + * {@link csFederationMiddleware}, which applies it) on EVERY path from which + * this function is reachable, remembering that Next.js middleware `matcher`s + * routinely exclude paths. Cryptographically pinning the payload to the app + * (AEAD seal/open with an app-held key) so an un-stripped header can't be forged + * is tracked in CIP-3112. + * + * @param {{ get(name: string): string | null }} headers + * @param {CsAuthHeaderOptions} [options] + * @returns {WarmedAuthStrategy | null} + */ +export function csAuthHeader(headers, options) { + const headerName = options?.headerName ?? CS_TOKEN_HEADER; + const raw = headers?.get(headerName) ?? null; + if (!raw) return null; + let warmed; + try { + warmed = decodeTokenHeader(raw); + } catch { + return null; + } + // Validate the FULL TokenResult shape, not just `token`. The header is + // attacker-influenceable (a client could send its own `x-cs-cts-token`), so a + // malformed/spoofed payload must be rejected here rather than surfacing as + // undefined `subject`/`workspaceId`/`issuer`/`services` fields downstream. + if (!isTokenResult(warmed)) return null; + // Mirror a real strategy's Result-returning `getToken()` so this warmed + // strategy stays a drop-in wherever an `OidcFederationStrategy` / + // `AccessKeyStrategy` is consumed (e.g. protect-ffi). The warmed token is + // already validated, so it's always a `{ data }` success. + return { + requiresFederation: false, + getToken: async () => ({ data: warmed }), + free() {}, + }; +} + +/** + * Structural guard for a decoded {@link import("./wasm-types.d.ts").TokenResult}: + * `token`/`subject`/`workspaceId`/`issuer` are non-empty strings and `services` + * is a NON-EMPTY string→string map. A federated CTS token always carries at + * least one service endpoint (e.g. `zerokms`), so an empty `services` signals a + * partial/spoofed payload — reject it here rather than let the consumer read an + * `undefined` endpoint. Rejection is safe: the caller falls back to a cold + * federation, which re-derives the real token. + * + * @param {unknown} v + * @returns {v is import("./wasm-types.d.ts").TokenResult} + */ +function isTokenResult(v) { + if (!v || typeof v !== "object") return false; + for (const key of ["token", "subject", "workspaceId", "issuer"]) { + if (typeof v[key] !== "string" || v[key].length === 0) return false; + } + if (!v.services || typeof v.services !== "object") return false; + const endpoints = Object.values(v.services); + if (endpoints.length === 0) return false; + for (const endpoint of endpoints) { + if (typeof endpoint !== "string") return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Header payload codec — base64url(JSON) keeps the value header-safe and opaque. +// NOTE: this is opaque, NOT authenticated — see the security caveat on +// csAuthHeader. base64url primitives are shared with `/cookies` via base64url.mjs. +// --------------------------------------------------------------------------- + +/** + * @param {import("./wasm-types.d.ts").TokenResult} result + * @returns {string} + */ +export function encodeTokenHeader(result) { + return encodeBase64Url(JSON.stringify(result)); +} + +/** + * @param {string} value + * @returns {import("./wasm-types.d.ts").TokenResult} + */ +export function decodeTokenHeader(value) { + return JSON.parse(decodeBase64Url(value)); +} diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json new file mode 100644 index 000000000..dfb1f1255 --- /dev/null +++ b/languages/typescript/packages/auth/package.json @@ -0,0 +1,111 @@ +{ + "name": "@cipherstash/auth", + "version": "0.44.0", + "license": "SEE LICENSE IN LICENSE", + "main": "index.js", + "types": "index.d.ts", + "browser": false, + "exports": { + ".": { + "node": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "default": { + "types": "./wasm-types.d.ts", + "default": "./wasm/stack_auth_wasm.js" + } + }, + "./wasm": { + "types": "./wasm-types.d.ts", + "default": "./wasm/stack_auth_wasm.js" + }, + "./wasm-inline": { + "types": "./wasm-inline.d.ts", + "default": "./wasm-inline.mjs" + }, + "./cookies": { + "types": "./cookies.d.ts", + "default": "./cookies.mjs" + }, + "./next": { + "types": "./next.d.ts", + "default": "./next.mjs" + } + }, + "napi": { + "name": "stack-auth-node", + "triples": { + "defaults": false, + "additional": [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "x86_64-pc-windows-msvc" + ] + } + }, + "files": [ + "index.js", + "index.d.ts", + "native.d.ts", + "README.md", + "LICENSE", + "stack-auth-node.js", + "wasm-types.d.ts", + "wasm-inline.mjs", + "wasm-inline.d.ts", + "cookies.mjs", + "cookies.d.ts", + "base64url.mjs", + "base64url.d.ts", + "next.mjs", + "next.d.ts", + "wasm/" + ], + "scripts": { + "build:native": "napi build --release --dts native.d.ts", + "build:debug": "napi build --dts native.d.ts", + "build:wasm": "cd ../stack-auth-wasm && wasm-pack build --target bundler --out-dir ../auth/wasm && rm -f ../auth/wasm/README.md ../auth/wasm/LICENSE ../auth/wasm/.gitignore && echo '{\"type\":\"module\"}' > ../auth/wasm/package.json && node ../auth/scripts/inline-wasm.mjs", + "test": "vitest run && biome check .", + "test:cargo": "cargo test --locked" + }, + "peerDependencies": { + "@cipherstash/auth-darwin-x64": "workspace:*", + "@cipherstash/auth-darwin-arm64": "workspace:*", + "@cipherstash/auth-linux-x64-gnu": "workspace:*", + "@cipherstash/auth-linux-arm64-gnu": "workspace:*", + "@cipherstash/auth-linux-x64-musl": "workspace:*", + "@cipherstash/auth-win32-x64-msvc": "workspace:*" + }, + "peerDependenciesMeta": { + "@cipherstash/auth-darwin-x64": { + "optional": true + }, + "@cipherstash/auth-darwin-arm64": { + "optional": true + }, + "@cipherstash/auth-linux-x64-gnu": { + "optional": true + }, + "@cipherstash/auth-linux-arm64-gnu": { + "optional": true + }, + "@cipherstash/auth-linux-x64-musl": { + "optional": true + }, + "@cipherstash/auth-win32-x64-msvc": { + "optional": true + } + }, + "dependencies": { + "@byteslice/result": "^0.3.0" + }, + "devDependencies": { + "@napi-rs/cli": "^2", + "typescript": "^5", + "vitest": "^3" + } +} diff --git a/languages/typescript/packages/auth/platforms/darwin-arm64/README.md b/languages/typescript/packages/auth/platforms/darwin-arm64/README.md new file mode 100644 index 000000000..d119eb217 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/darwin-arm64/README.md @@ -0,0 +1,3 @@ +# `@cipherstash/auth-darwin-arm64` + +This is the **aarch64-apple-darwin** binary for `@cipherstash/auth` diff --git a/languages/typescript/packages/auth/platforms/darwin-arm64/package.json b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json new file mode 100644 index 000000000..219540b0d --- /dev/null +++ b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@cipherstash/auth-darwin-arm64", + "version": "0.44.0", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "main": "stack-auth-node.darwin-arm64.node", + "files": [ + "stack-auth-node.darwin-arm64.node" + ] +} \ No newline at end of file diff --git a/languages/typescript/packages/auth/platforms/darwin-x64/README.md b/languages/typescript/packages/auth/platforms/darwin-x64/README.md new file mode 100644 index 000000000..5303e9401 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/darwin-x64/README.md @@ -0,0 +1,3 @@ +# `@cipherstash/auth-darwin-x64` + +This is the **x86_64-apple-darwin** binary for `@cipherstash/auth` diff --git a/languages/typescript/packages/auth/platforms/darwin-x64/package.json b/languages/typescript/packages/auth/platforms/darwin-x64/package.json new file mode 100644 index 000000000..402aff111 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/darwin-x64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@cipherstash/auth-darwin-x64", + "version": "0.44.0", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "main": "stack-auth-node.darwin-x64.node", + "files": [ + "stack-auth-node.darwin-x64.node" + ] +} \ No newline at end of file diff --git a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/README.md b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/README.md new file mode 100644 index 000000000..47761b321 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/README.md @@ -0,0 +1,3 @@ +# `@cipherstash/auth-linux-arm64-gnu` + +This is the **aarch64-unknown-linux-gnu** binary for `@cipherstash/auth` diff --git a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json new file mode 100644 index 000000000..04a40099d --- /dev/null +++ b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json @@ -0,0 +1,17 @@ +{ + "name": "@cipherstash/auth-linux-arm64-gnu", + "version": "0.44.0", + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "main": "stack-auth-node.linux-arm64-gnu.node", + "files": [ + "stack-auth-node.linux-arm64-gnu.node" + ], + "libc": [ + "glibc" + ] +} \ No newline at end of file diff --git a/languages/typescript/packages/auth/platforms/linux-x64-gnu/README.md b/languages/typescript/packages/auth/platforms/linux-x64-gnu/README.md new file mode 100644 index 000000000..aeb8b3510 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/linux-x64-gnu/README.md @@ -0,0 +1,3 @@ +# `@cipherstash/auth-linux-x64-gnu` + +This is the **x86_64-unknown-linux-gnu** binary for `@cipherstash/auth` diff --git a/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json new file mode 100644 index 000000000..3ef4be204 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json @@ -0,0 +1,17 @@ +{ + "name": "@cipherstash/auth-linux-x64-gnu", + "version": "0.44.0", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "main": "stack-auth-node.linux-x64-gnu.node", + "files": [ + "stack-auth-node.linux-x64-gnu.node" + ], + "libc": [ + "glibc" + ] +} \ No newline at end of file diff --git a/languages/typescript/packages/auth/platforms/linux-x64-musl/README.md b/languages/typescript/packages/auth/platforms/linux-x64-musl/README.md new file mode 100644 index 000000000..81bfb12c9 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/linux-x64-musl/README.md @@ -0,0 +1,3 @@ +# `@cipherstash/auth-linux-x64-musl` + +This is the **x86_64-unknown-linux-musl** binary for `@cipherstash/auth` diff --git a/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json new file mode 100644 index 000000000..09fd6602a --- /dev/null +++ b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json @@ -0,0 +1,17 @@ +{ + "name": "@cipherstash/auth-linux-x64-musl", + "version": "0.44.0", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "main": "stack-auth-node.linux-x64-musl.node", + "files": [ + "stack-auth-node.linux-x64-musl.node" + ], + "libc": [ + "musl" + ] +} \ No newline at end of file diff --git a/languages/typescript/packages/auth/platforms/win32-x64-msvc/README.md b/languages/typescript/packages/auth/platforms/win32-x64-msvc/README.md new file mode 100644 index 000000000..05ec0899d --- /dev/null +++ b/languages/typescript/packages/auth/platforms/win32-x64-msvc/README.md @@ -0,0 +1,3 @@ +# `@cipherstash/auth-win32-x64-msvc` + +This is the **x86_64-pc-windows-msvc** binary for `@cipherstash/auth` diff --git a/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json new file mode 100644 index 000000000..f4b5501c0 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json @@ -0,0 +1,14 @@ +{ + "name": "@cipherstash/auth-win32-x64-msvc", + "version": "0.44.0", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "main": "stack-auth-node.win32-x64-msvc.node", + "files": [ + "stack-auth-node.win32-x64-msvc.node" + ] +} \ No newline at end of file diff --git a/languages/typescript/packages/auth/scripts/inline-wasm.mjs b/languages/typescript/packages/auth/scripts/inline-wasm.mjs new file mode 100644 index 000000000..e9546e08c --- /dev/null +++ b/languages/typescript/packages/auth/scripts/inline-wasm.mjs @@ -0,0 +1,41 @@ +// Generates the `@cipherstash/auth/wasm-inline` entry from wasm-pack output. +// Required because `--target bundler` emits `import * as wasm from "./*.wasm"`, +// which fails in runtimes that don't auto-bundle sibling `.wasm` assets — see +// README's "Why the explicit sub-path" section for the full rationale. + +import { readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const wasmDir = resolve(here, '..', 'wasm') + +const wasmBytes = await readFile(resolve(wasmDir, 'stack_auth_wasm_bg.wasm')) +const base64 = wasmBytes.toString('base64') + +const shim = `/* @ts-self-types="./stack_auth_wasm.d.ts" */ +// Generated by scripts/inline-wasm.mjs — do not edit. + +import * as bgImports from "./stack_auth_wasm_bg.js"; + +const WASM_BYTES_B64 = "${base64}"; +const wasmBytes = Uint8Array.from(atob(WASM_BYTES_B64), (c) => c.charCodeAt(0)); + +const { instance } = await WebAssembly.instantiate(wasmBytes, { + "./stack_auth_wasm_bg.js": bgImports, +}); +bgImports.__wbg_set_wasm(instance.exports); +instance.exports.__wbindgen_start(); + +export { + AccessKeyStrategy, OidcFederationStrategy, IntoUnderlyingByteSource, IntoUnderlyingSink, IntoUnderlyingSource, module_init +} from "./stack_auth_wasm_bg.js"; +` + +const outPath = resolve(wasmDir, 'stack_auth_wasm_inline.js') +await writeFile(outPath, shim) + +console.log( + `inline-wasm: wrote stack_auth_wasm_inline.js (` + + `${wasmBytes.length} wasm bytes -> ${base64.length} b64 chars)`, +) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs new file mode 100644 index 000000000..e8e3b92e1 --- /dev/null +++ b/languages/typescript/packages/auth/src/lib.rs @@ -0,0 +1,1387 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use cts_common::Region; +use napi::bindgen_prelude::*; +use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode}; +use napi::tokio::sync::oneshot; +use napi_derive::napi; +use stack_auth::{ + AlreadyConsumed, AuthError, AuthStrategy, DeviceClientError, DeviceCodeStrategy, InternalError, + OidcProvider, PendingDeviceCode, SecretToken, ServerError, ServiceToken, Token, TokenStore, +}; +use vitaminc_protected::OpaqueDebug; +use zeroize::Zeroizing; + +// --------------------------------------------------------------------------- +// Error helpers +// --------------------------------------------------------------------------- + +/// Sentinel prefix that marks a `napi::Error` whose `reason` carries a +/// serialized [`AuthError`] (a domain failure) rather than an arbitrary throw. +/// `index.js` keys on this to convert the rejection into a `Result` `failure` +/// envelope; anything without it is re-thrown as a genuine error/panic. +const FAILURE_SENTINEL: &str = "__CS_FAIL__"; + +fn to_napi_error(err: AuthError) -> napi::Error { + // `napi::Error` only carries a string `reason`, so the structured failure + // (`{ type, message, help?, url?, ...payload }`) travels as a JSON blob + // behind the sentinel. `index.js` parses it back into the `Result` failure. + let json = serde_json::to_string(&err).unwrap_or_else(|_| { + serde_json::json!({ "type": err.error_code(), "message": err.to_string() }).to_string() + }); + napi::Error::new(Status::GenericFailure, format!("{FAILURE_SENTINEL}{json}")) +} + +/// Surface a JS callback failure on stderr so it isn't silently swallowed — +/// the node counterpart to the wasm binding's `warn_callback` (`console.warn`). +fn warn_callback(name: &str, detail: &str) { + eprintln!("stack-auth: {name} {detail}"); +} + +/// Parse a workspace CRN string, mapping a parse failure to the `INVALID_CRN` +/// error code. Shared by every factory that takes a workspace CRN +/// (`AccessKeyStrategy`, `AutoStrategy`, `OidcFederationStrategy`). +fn parse_workspace_crn(workspace_crn: &str) -> Result<cts_common::Crn> { + workspace_crn + .parse() + .map_err(|e| to_napi_error(AuthError::from(e))) +} + +// --------------------------------------------------------------------------- +// TokenResult — returned by strategy.getToken() +// --------------------------------------------------------------------------- + +/// The result of a successful `getToken()` call. +/// +/// Contains the bearer credential and decoded JWT claims for service discovery. +#[derive(OpaqueDebug)] +#[napi(object)] +pub struct TokenResult { + /// The bearer token string (used as `Authorization: Bearer <token>`). + pub token: String, + /// The subject claim from the JWT (e.g. `"CS|auth0|user123"` or `"CS|CSAKkeyId"`). + pub subject: String, + /// The workspace identifier from the JWT. + pub workspace_id: String, + /// The issuer URL from the JWT `iss` claim (i.e. the CTS host). + pub issuer: String, + /// Service endpoint URLs from the JWT `services` claim (e.g. `{ zerokms: "https://..." }`). + pub services: HashMap<String, String>, +} + +fn token_result_from(token: ServiceToken) -> Result<TokenResult> { + let subject = token.subject().map_err(to_napi_error)?.to_string(); + let workspace_id = token.workspace_id().map_err(to_napi_error)?.to_string(); + let issuer = token.issuer().map_err(to_napi_error)?.to_string(); + let services = token + .services() + .map_err(to_napi_error)? + .iter() + .map(|(k, v)| (k.as_str().to_string(), v.to_string())) + .collect(); + + Ok(TokenResult { + token: token.as_str().to_string(), + subject, + workspace_id, + issuer, + services, + }) +} + +// --------------------------------------------------------------------------- +// AutoStrategy — auto-detect credentials +// --------------------------------------------------------------------------- + +/// Options for `AutoStrategy.detect()`. +#[derive(OpaqueDebug)] +#[napi(object)] +pub struct AutoStrategyOptions { + /// An explicit access key (takes precedence over `CS_CLIENT_ACCESS_KEY` env var). + pub access_key: Option<String>, + /// An explicit workspace CRN (takes precedence over `CS_WORKSPACE_CRN` env var). + pub workspace_crn: Option<String>, +} + +/// An auth strategy that auto-detects credentials from environment variables +/// and the local profile store. +/// +/// Detection order: +/// 1. `CS_CLIENT_ACCESS_KEY` env var (or explicit `accessKey` option) → access key auth +/// 2. `~/.cipherstash/auth.json` → OAuth token auth +/// 3. Error: not authenticated +#[napi] +pub struct AutoStrategy { + inner: stack_auth::AutoStrategy, +} + +#[napi] +impl AutoStrategy { + /// Detect available credentials and return an `AutoStrategy`. + /// + /// Pass options to provide explicit values that take precedence over + /// environment variables. + #[napi(factory)] + pub fn detect(options: Option<AutoStrategyOptions>) -> Result<Self> { + let mut builder = stack_auth::AutoStrategy::builder(); + + if let Some(opts) = options { + if let Some(key) = opts.access_key { + builder = builder.with_access_key(key); + } + if let Some(crn_str) = opts.workspace_crn { + builder = builder.with_workspace_crn(parse_workspace_crn(&crn_str)?); + } + } + + let inner = builder.detect().map_err(to_napi_error)?; + Ok(Self { inner }) + } + + /// Retrieve a valid access token, refreshing or re-authenticating as needed. + #[napi] + pub async fn get_token(&self) -> Result<TokenResult> { + let token = (&self.inner).get_token().await.map_err(to_napi_error)?; + token_result_from(token) + } +} + +// --------------------------------------------------------------------------- +// AccessKeyStrategy — static access key auth +// --------------------------------------------------------------------------- + +/// An auth strategy that uses a static access key for service-to-service +/// or CI/CD authentication. +#[napi] +pub struct AccessKeyStrategy { + inner: stack_auth::AccessKeyStrategy, +} + +#[napi] +impl AccessKeyStrategy { + /// Create a new `AccessKeyStrategy` for the given workspace CRN and + /// access key. + /// + /// The CRN format is `crn:<region>:<workspace-id>` (e.g. + /// `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed + /// from the CRN and used for service discovery; the workspace ID is + /// used to verify every issued token belongs to the right workspace. + /// A mismatch fails `getToken()` with `code === "WORKSPACE_MISMATCH"`. + #[napi(factory)] + pub fn create(workspace_crn: String, access_key: String) -> Result<Self> { + let crn = parse_workspace_crn(&workspace_crn)?; + let key: stack_auth::AccessKey = access_key + .parse() + .map_err(|e| to_napi_error(AuthError::from(e)))?; + let inner = stack_auth::AccessKeyStrategy::new(crn, key).map_err(to_napi_error)?; + Ok(Self { inner }) + } + + /// Retrieve a valid access token, refreshing or re-authenticating as needed. + #[napi] + pub async fn get_token(&self) -> Result<TokenResult> { + let token = (&self.inner).get_token().await.map_err(to_napi_error)?; + token_result_from(token) + } +} + +// --------------------------------------------------------------------------- +// DeviceSessionStrategy — OAuth with profile store +// --------------------------------------------------------------------------- + +/// An auth strategy that uses OAuth refresh tokens persisted to disk +/// (`~/.cipherstash/auth.json`). +#[napi] +pub struct DeviceSessionStrategy { + inner: stack_auth::DeviceSessionStrategy, +} + +#[napi] +impl DeviceSessionStrategy { + /// Load credentials from the default profile store and create a `DeviceSessionStrategy`. + #[napi(factory)] + pub fn from_profile() -> Result<Self> { + let store = stack_profile::ProfileStore::resolve(None) + .map_err(|e| to_napi_error(AuthError::from(e)))?; + let inner = stack_auth::DeviceSessionStrategy::with_profile(store) + .build() + .map_err(to_napi_error)?; + Ok(Self { inner }) + } + + /// Retrieve a valid access token, refreshing as needed. + #[napi] + pub async fn get_token(&self) -> Result<TokenResult> { + let token = (&self.inner).get_token().await.map_err(to_napi_error)?; + token_result_from(token) + } +} + +// --------------------------------------------------------------------------- +// OidcFederationStrategy — federate a third-party OIDC JWT into a CTS service token +// --------------------------------------------------------------------------- + +/// Bridges a JS `getJwt` callback into a Rust [`OidcProvider`]. +/// +/// `getJwt` is a JS function returning `Promise<string>` — the current +/// third-party OIDC JWT. The threadsafe function lets the Rust refresh engine +/// (running on napi's tokio pool) schedule the call onto the Node event-loop +/// thread; the JS-returned `Promise` is ferried back and awaited here. +struct NapiOidcProvider { + get_jwt: ThreadsafeFunction<(), ErrorStrategy::Fatal>, +} + +impl OidcProvider for NapiOidcProvider { + async fn fetch(&self) -> std::result::Result<SecretToken, AuthError> { + let (tx, rx) = oneshot::channel::<Promise<String>>(); + let status = self.get_jwt.call_with_return_value( + (), + ThreadsafeFunctionCallMode::NonBlocking, + move |promise: Promise<String>| { + let _ = tx.send(promise); + Ok(()) + }, + ); + // A `getJwt` failure is fatal — federation can't proceed without a JWT — + // so each arm logs the JS-side cause before surfacing the `AuthError`, + // mirroring the wasm binding's `warn_callback`. Without this the error + // reaches the caller with no breadcrumb of *why* the callback failed. + if status != Status::Ok { + let detail = format!("callback dispatch failed: {status:?}"); + warn_callback("getJwt", &detail); + return Err(AuthError::Server(ServerError(format!("getJwt {detail}")))); + } + let promise = rx.await.map_err(|_| { + warn_callback("getJwt", "callback did not run"); + AuthError::Server(ServerError("getJwt callback did not run".to_string())) + })?; + // `SecretToken` owns the JWT and zeroes it on drop (it's `ZeroizeOnDrop`), + // so the awaited `String` moves straight in — no intermediate `Zeroizing`. + let jwt = promise.await.map_err(|e| { + warn_callback("getJwt", &format!("promise rejected: {e}")); + AuthError::Server(ServerError(format!("getJwt rejected: {e}"))) + })?; + Ok(SecretToken::new(jwt)) + } +} + +/// Bridges JS `loadToken` / `saveToken` callbacks into a Rust [`TokenStore`]. +/// +/// Both are best-effort, mirroring [`stack_auth::TokenStoreFn`] semantics: a +/// `load` failure becomes a cache miss, a `save` failure is swallowed. +struct NapiTokenStore { + load: ThreadsafeFunction<(), ErrorStrategy::Fatal>, + save: ThreadsafeFunction<String, ErrorStrategy::Fatal>, +} + +impl TokenStore for NapiTokenStore { + async fn load(&self) -> Option<Token> { + let (tx, rx) = oneshot::channel::<Promise<Option<String>>>(); + let status = self.load.call_with_return_value( + (), + ThreadsafeFunctionCallMode::NonBlocking, + move |promise: Promise<Option<String>>| { + let _ = tx.send(promise); + Ok(()) + }, + ); + if status != Status::Ok { + return None; + } + let json = Zeroizing::new(rx.await.ok()?.await.ok()??); + serde_json::from_str(&json).ok() + } + + async fn save(&self, token: &Token) { + let Ok(json) = serde_json::to_string(token).map(Zeroizing::new) else { + return; + }; + let (tx, rx) = oneshot::channel::<Promise<()>>(); + let status = self.save.call_with_return_value( + json.to_string(), + ThreadsafeFunctionCallMode::NonBlocking, + move |promise: Promise<()>| { + let _ = tx.send(promise); + Ok(()) + }, + ); + if status != Status::Ok { + return; + } + if let Ok(promise) = rx.await { + let _ = promise.await; + } + } +} + +enum OidcFederationStrategyInner { + NoStore(stack_auth::OidcFederationStrategy<NapiOidcProvider>), + WithStore(stack_auth::OidcFederationStrategy<NapiOidcProvider, NapiTokenStore>), +} + +/// An auth strategy that federates a third-party OIDC JWT (Clerk, Supabase, …) +/// into a CipherStash CTS service token via `/api/authorise`. +#[napi] +pub struct OidcFederationStrategy { + inner: OidcFederationStrategyInner, +} + +#[napi] +impl OidcFederationStrategy { + /// Create an `OidcFederationStrategy` for the given workspace CRN. + /// + /// The CRN format is `crn:<region>:<workspace-id>` (e.g. + /// `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from + /// the CRN and used for service discovery; the workspace ID is used to + /// verify every federated token belongs to the right workspace. + /// + /// `getJwt` is called on every federation — initial auth and every + /// re-federation after the CTS token expires — and must return + /// `Promise<string>` resolving to the *current* third-party OIDC JWT. + /// + /// `baseUrl`, when supplied, pins this strategy to a specific CTS host — + /// e.g. a self-hosted CTS or a local mock auth server. It takes precedence + /// over the `CS_CTS_HOST` environment variable and region service + /// discovery, and is scoped to this strategy alone (unlike `CS_CTS_HOST`, + /// which redirects every CTS client in the process). + #[napi(factory)] + pub fn create( + workspace_crn: String, + get_jwt: ThreadsafeFunction<(), ErrorStrategy::Fatal>, + base_url: Option<String>, + ) -> Result<Self> { + let crn = parse_workspace_crn(&workspace_crn)?; + let inner = stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }) + .maybe_base_url(base_url) + .map_err(to_napi_error)? + .build() + .map_err(to_napi_error)?; + Ok(Self { + inner: OidcFederationStrategyInner::NoStore(inner), + }) + } + + /// Create an `OidcFederationStrategy` backed by external token-store callbacks. + /// + /// Behaves like `create` but persists the federated CTS + /// token through `loadToken` (`() => Promise<string | null | undefined>`) + /// and `saveToken` (`(json: string) => Promise<void>`) — e.g. an HTTP-only + /// cookie — so a federated token survives across requests without + /// re-federating. + /// + /// `baseUrl` behaves as in `create` — an explicit, + /// strategy-scoped CTS host that overrides `CS_CTS_HOST` and service + /// discovery. + #[napi(factory)] + pub fn create_with_store( + workspace_crn: String, + get_jwt: ThreadsafeFunction<(), ErrorStrategy::Fatal>, + load_token: ThreadsafeFunction<(), ErrorStrategy::Fatal>, + save_token: ThreadsafeFunction<String, ErrorStrategy::Fatal>, + base_url: Option<String>, + ) -> Result<Self> { + let crn = parse_workspace_crn(&workspace_crn)?; + let store = NapiTokenStore { + load: load_token, + save: save_token, + }; + let inner = stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }) + .maybe_base_url(base_url) + .map_err(to_napi_error)? + .with_token_store(store) + .build() + .map_err(to_napi_error)?; + Ok(Self { + inner: OidcFederationStrategyInner::WithStore(inner), + }) + } + + /// Retrieve a valid CTS service token, federating or re-federating as needed. + #[napi] + pub async fn get_token(&self) -> Result<TokenResult> { + let token = match &self.inner { + OidcFederationStrategyInner::NoStore(s) => s.get_token().await, + OidcFederationStrategyInner::WithStore(s) => s.get_token().await, + } + .map_err(to_napi_error)?; + token_result_from(token) + } +} + +// --------------------------------------------------------------------------- +// AuthResult — plain data object (device code flow) +// --------------------------------------------------------------------------- + +/// Metadata returned after a successful device code authentication. +/// +/// The actual token is never exposed to JavaScript — it is saved directly +/// to `~/.cipherstash/auth.json` by the Rust layer. +#[derive(Debug)] +#[napi(object)] +pub struct AuthResult { + /// Absolute epoch timestamp (seconds) when the token expires. + pub expires_at: f64, + /// Number of seconds before the token expires (computed at time of return). + pub expires_in: f64, +} + +// --------------------------------------------------------------------------- +// DeviceCodeResult — class with methods +// --------------------------------------------------------------------------- + +#[derive(Debug)] +#[napi] +pub struct DeviceCodeResult { + /// The short code the user must enter to authorize this device. + user_code: String, + /// The base verification URI (without the user code embedded). + verification_uri: String, + /// The full verification URI with the user code pre-filled. + verification_uri_complete: String, + /// How many seconds the device code remains valid. + expires_in: f64, + /// The pending device code handle (consumed by `pollForToken`). + pending: Mutex<Option<PendingDeviceCode>>, +} + +#[napi] +impl DeviceCodeResult { + #[napi(getter)] + pub fn user_code(&self) -> String { + self.user_code.clone() + } + + #[napi(getter)] + pub fn verification_uri(&self) -> String { + self.verification_uri.clone() + } + + #[napi(getter, js_name = "verificationUriComplete")] + pub fn verification_uri_complete(&self) -> String { + self.verification_uri_complete.clone() + } + + #[napi(getter)] + pub fn expires_in(&self) -> f64 { + self.expires_in + } + + /// Poll the auth server until the user completes authorization. + /// + /// **Consumes** the internal handle — it cannot be reused after this call. + /// If you need to open the browser, call `openInBrowser` *before* + /// `pollForToken`. + #[napi] + pub async fn poll_for_token(&self) -> Result<AuthResult> { + let pending = self + .pending + .lock() + .map_err(|_| to_napi_error(AuthError::Internal(InternalError("lock poisoned".into()))))? + .take() + .ok_or_else(|| to_napi_error(AuthError::AlreadyConsumed(AlreadyConsumed)))?; + + let token = pending.poll_for_token().await.map_err(to_napi_error)?; + + Ok(AuthResult { + expires_at: token.expires_at() as f64, + expires_in: token.expires_in() as f64, + }) + } + + /// Open the verification URI in the user's default browser. + /// + /// Does **not** consume the handle — you can still call `pollForToken` + /// afterwards. + #[napi] + pub fn open_in_browser(&self) -> Result<bool> { + let guard = self.pending.lock().map_err(|_| { + to_napi_error(AuthError::Internal(InternalError("lock poisoned".into()))) + })?; + + match guard.as_ref() { + Some(pending) => Ok(pending.open_in_browser()), + None => Err(to_napi_error(AuthError::AlreadyConsumed(AlreadyConsumed))), + } + } +} + +impl DeviceCodeResult { + fn from_pending(pending: PendingDeviceCode) -> Self { + Self { + user_code: pending.user_code().to_string(), + verification_uri: pending.verification_uri().to_string(), + verification_uri_complete: pending.verification_uri_complete().to_string(), + expires_in: pending.expires_in() as f64, + pending: Mutex::new(Some(pending)), + } + } +} + +// --------------------------------------------------------------------------- +// Exported functions +// --------------------------------------------------------------------------- + +fn device_client_to_napi_error(err: DeviceClientError) -> napi::Error { + // Route through the canonical `AuthError` mapping (`From<DeviceClientError>` + // in stack-auth) so the code/help/payload envelope comes from the one + // `to_napi_error` path rather than a parallel code table and hand-built blob. + to_napi_error(err.into()) +} + +/// Provision a device client in ZeroKMS after login. +/// +/// Loads the auth token and device identity from `~/.cipherstash/`, +/// creates a client on the workspace's default keyset, and persists the +/// resulting secret key to `~/.cipherstash/secretkey.json`. +/// +/// This is a no-op if the secret key already exists or the server returns +/// 409 (conflict). +#[napi] +pub async fn bind_client_device() -> Result<()> { + let store = stack_profile::ProfileStore::resolve(None) + .map_err(|e| device_client_to_napi_error(DeviceClientError::from(e)))?; + stack_auth::bind_client_device(&store) + .await + .map_err(device_client_to_napi_error) +} + +/// Begin the OAuth 2.0 Device Authorization flow. +#[napi] +pub async fn begin_device_code_flow(region: String, client_id: String) -> Result<DeviceCodeResult> { + let region = Region::new(&region).map_err(|e| to_napi_error(AuthError::from(e)))?; + let strategy = DeviceCodeStrategy::new(region, client_id).map_err(to_napi_error)?; + let pending = strategy.begin().await.map_err(to_napi_error)?; + Ok(DeviceCodeResult::from_pending(pending)) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use cts_common::Region; + use mocktail::prelude::*; + use tempfile::TempDir; + + /// The hand-written `AuthFailure` discriminated unions in `index.d.ts` and + /// `wasm-inline.d.ts` must list exactly the codes the Rust `AuthError` can + /// emit. The expected set is the exported [`AuthError::ERROR_CODES`] + /// constant — a real symbol the compiler resolves, not a scrape of the core + /// crate's source text. A core-crate test pins that constant against the + /// per-error `AuthErrorKind::error_code` impls, so adding an `AuthError` + /// variant forces a new code there, which this test then requires the TS + /// unions to include; forget to update them and this fails. + #[test] + fn ts_auth_failure_union_matches_error_codes() { + use std::collections::BTreeSet; + + // The codes the Rust `AuthError` can emit — the canonical set exported + // by stack-auth, not a scrape of `error.rs`. + let expected: BTreeSet<&str> = AuthError::ERROR_CODES.iter().copied().collect(); + + // Each TS union member is `... { type: "CODE" ... }`; pull every literal. + let codes_in = |dts: &str| -> BTreeSet<String> { + dts.match_indices("type: \"") + .map(|(i, _)| { + let after = &dts[i + "type: \"".len()..]; + let close = after + .find('"') + .expect("TS union type missing closing quote"); + after[..close].to_string() + }) + .collect() + }; + + for (name, dts) in [ + ("index.d.ts", include_str!("../index.d.ts")), + ("wasm-inline.d.ts", include_str!("../wasm-inline.d.ts")), + ] { + let union = codes_in(dts); + let expected: BTreeSet<String> = expected.iter().map(|s| s.to_string()).collect(); + assert_eq!( + union, expected, + "AuthFailure union in {name} drifted from AuthError::ERROR_CODES", + ); + + // The tag scrape above only checks `type` literals. `WORKSPACE_MISMATCH` + // is the one variant carrying a structured payload (`WorkspaceMismatch::payload` + // in error.rs emits `expected`/`actual`), so pin those field names in the + // union too — a serde key rename or a dropped `.d.ts` field would otherwise + // leave the declared shape silently lying. A JS runtime test + // (`oidc-federation-strategy.test.ts`) drives it end-to-end through the + // `...payload` spread. + assert!( + dts.contains("type: \"WORKSPACE_MISMATCH\"; expected: string; actual: string"), + "{name}: WORKSPACE_MISMATCH union member must declare `expected: string; actual: string`", + ); + } + } + + /// `wasm-types.d.ts` declares the same taxonomy in a different shape — a + /// bare `| 'CODE'` union rather than `FailureBase & { type: "CODE" }` — so + /// the scrape above cannot see it. It went unchecked long enough to grow a + /// phantom `UNKNOWN_ERROR` and lose three real codes. + /// + /// The wasm build has no `Store` variant (see the `cfg` on `AuthError`), + /// so its union is `ERROR_CODES` minus `STORE_ERROR`. + #[test] + fn wasm_types_union_matches_error_codes() { + use std::collections::BTreeSet; + + let dts = include_str!("../wasm-types.d.ts"); + + let union: BTreeSet<&str> = dts + .match_indices("| '") + .map(|(i, _)| { + let after = &dts[i + "| '".len()..]; + let close = after + .find('\'') + .expect("TS union member missing close quote"); + &after[..close] + }) + .collect(); + + let expected: BTreeSet<&str> = AuthError::ERROR_CODES + .iter() + .copied() + .filter(|code| *code != "STORE_ERROR") + .collect(); + + assert_eq!( + union, expected, + "AuthErrorCode union in wasm-types.d.ts drifted from AuthError::ERROR_CODES", + ); + } + + #[test] + fn index_dts_retains_hand_written_reexports() { + // The union test above only guards the `AuthFailure` codes. The other + // hand-written pieces of `index.d.ts` are equally load-bearing but + // NAPI-RS cannot emit them — they describe the `Result` contract, not the + // raw throwing bindings — so a regen or careless edit that drops any of + // them compiles green: the node tests import these as `import type` + // (erased at runtime) and vitest never runs `tsc`. Pin them by string + // presence so a deletion fails here. + let dts = include_str!("../index.d.ts"); + for needle in [ + // The native type re-use that ties this file to `native.d.ts`. + "from \"./native\"", + // The discriminated failure union returned in the `Result` arm. + "export type AuthFailure =", + // The deprecated runtime alias `index.js` still exports. + "export declare const OAuthStrategy", + ] { + assert!( + dts.contains(needle), + "index.d.ts lost hand-written {needle:?}", + ); + } + } + + /// The `__CS_FAIL__` sentinel is declared in both Rust (`FAILURE_SENTINEL`) + /// and JS (`index.js`), and every napi domain error depends on the two + /// agreeing: `toFailure` only recognizes a failure whose message starts with + /// it, re-throwing anything else as a panic. If the two drift, every failure + /// silently becomes a thrown error and no other test catches it. Pin that + /// `index.js` declares exactly the Rust value. + #[test] + fn failure_sentinel_matches_index_js() { + let js = include_str!("../index.js"); + let expected = format!("const FAILURE_SENTINEL = \"{FAILURE_SENTINEL}\";"); + assert!( + js.contains(&expected), + "index.js must declare `{expected}` — the __CS_FAIL__ sentinel drifted from src/lib.rs", + ); + } + + // --- Shared helpers --- + + fn device_code_json() -> serde_json::Value { + serde_json::json!({ + "device_code": "test_device_code", + "user_code": "ABCD-EFGH", + "verification_uri": "http://example.com/activate", + "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", + "expires_in": 900 + }) + } + + fn test_access_token_jwt() -> String { + jwt_with_workspace("ZVATKW3VHMFG27DY") + } + + /// Build a JWT carrying the given `workspace` claim. Used by the + /// workspace-verification regression tests to mint tokens whose + /// workspace claim is deliberately mismatched against the CRN passed + /// to the strategy. + fn jwt_with_workspace(workspace: &str) -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-user", + "aud": "test-audience", + "iat": now, + "exp": now + 3600, + "workspace": workspace, + "org_id": "org_test_default", + "scope": "", + }); + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .unwrap() + } + + fn token_json() -> serde_json::Value { + serde_json::json!({ + "access_token": test_access_token_jwt(), + "token_type": "Bearer", + "expires_in": 3600 + }) + } + + fn error_json(error: &str) -> serde_json::Value { + serde_json::json!({ + "error": error, + "error_description": format!("{error} occurred") + }) + } + + fn mock_code_endpoint(mocks: &mut MockSet) { + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.json(device_code_json()); + }); + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("stack-auth-node-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + /// Create a `DeviceCodeResult` by running the real `DeviceCodeStrategy` + /// against a mock server, then wrapping the `PendingDeviceCode`. + async fn begin_result(server: &MockServer, dir: &TempDir) -> DeviceCodeResult { + let strategy = + DeviceCodeStrategy::builder(Region::aws("ap-southeast-2").unwrap(), "test-client") + .base_url(server.url("")) + .profile_dir(dir.path()) + .build() + .unwrap(); + let pending = strategy.begin().await.unwrap(); + DeviceCodeResult::from_pending(pending) + } + + fn make_service_token(iss: &str, zerokms_url: &str) -> ServiceToken { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let claims = serde_json::json!({ + "iss": iss, + "sub": "CS|test-user", + "aud": "test-aud", + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "org_id": "org_test_default", + "scope": "", + "services": { "zerokms": zerokms_url }, + }); + + let jwt = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .unwrap(); + + ServiceToken::new(stack_auth::SecretToken::new(jwt)) + } + + /// Extract the error from a `Result<T, napi::Error>` without requiring + /// `T: Debug` (NAPI wrapper structs don't implement it). + fn expect_err<T>(result: Result<T>) -> napi::Error { + match result { + Err(e) => e, + Ok(_) => panic!("expected Err, got Ok"), + } + } + + mod assertions { + /// Parse the `__CS_FAIL__`-sentineled JSON failure envelope that a + /// `napi::Error` now carries (see `to_napi_error`). + pub(super) fn failure_json(err: &napi::Error) -> serde_json::Value { + let reason = err + .reason + .strip_prefix(crate::FAILURE_SENTINEL) + .unwrap_or_else(|| panic!("error reason missing failure sentinel: {}", err.reason)); + serde_json::from_str(reason) + .unwrap_or_else(|e| panic!("failure JSON did not parse ({e}): {reason}")) + } + + /// Assert the failure envelope's `type` matches the expected code. + pub(super) fn has_error_code(err: &napi::Error, expected_code: &str) { + let json = failure_json(err); + assert_eq!( + json.get("type").and_then(|v| v.as_str()), + Some(expected_code), + "expected type {expected_code:?} but got envelope: {json}" + ); + } + } + + // --- Error mapping --- + + mod error_mapping { + use super::*; + + // `to_napi_error` is the napi FFI seam: it serializes an `AuthError` + // into the `__CS_FAIL__`-sentineled JSON envelope (`{ type, message, + // help?, ...payload }`) that index.js turns into a `Result` failure. + // The canonical `error_code` mapping is exhaustively pinned in the core + // `stack-auth` crate; here we guard the FFI envelope shape itself. + #[test] + fn serializes_failure_envelope() { + let err = to_napi_error(AuthError::AccessDenied(stack_auth::AccessDenied)); + assertions::has_error_code(&err, "ACCESS_DENIED"); + + let err = to_napi_error(AuthError::Server(ServerError( + "something broke".to_string(), + ))); + let json = assertions::failure_json(&err); + assert_eq!(json["type"], "SERVER_ERROR"); + assert_eq!(json["message"], "Server error: something broke"); + } + + // A variant carrying structured payload + diagnostic help surfaces both + // in the envelope, so a JS consumer can narrow on them. + #[test] + fn envelope_includes_payload_and_help() { + let ws = |s: &str| s.parse::<cts_common::WorkspaceId>().unwrap(); + let err = to_napi_error(AuthError::WorkspaceMismatch( + stack_auth::WorkspaceMismatch { + expected_workspace: ws("ZVATKW3VHMFG27DY"), + token_workspace: ws("AAAAAAAAAAAAAAAA"), + }, + )); + let json = assertions::failure_json(&err); + assert_eq!(json["type"], "WORKSPACE_MISMATCH"); + assert_eq!(json["expected"], "ZVATKW3VHMFG27DY"); + assert_eq!(json["actual"], "AAAAAAAAAAAAAAAA"); + assert!( + json["help"].as_str().is_some(), + "expected help in envelope, got: {json}" + ); + } + + // `device_client_to_napi_error` routes every `DeviceClientError` through + // its canonical `AuthError` mapping. A help-carrying error (here an + // `Auth`-wrapped `WorkspaceMismatch`) must keep its help + structured + // payload; a help-less one (`Profile` -> `Store`) yields just + // type + message. A regression that dropped the canonical routing would + // lose the help/payload here. + #[test] + fn device_client_auth_arm_preserves_full_envelope() { + let ws = |s: &str| s.parse::<cts_common::WorkspaceId>().unwrap(); + let err = device_client_to_napi_error(DeviceClientError::Auth( + AuthError::WorkspaceMismatch(stack_auth::WorkspaceMismatch { + expected_workspace: ws("ZVATKW3VHMFG27DY"), + token_workspace: ws("AAAAAAAAAAAAAAAA"), + }), + )); + let json = assertions::failure_json(&err); + assert_eq!(json["type"], "WORKSPACE_MISMATCH"); + assert_eq!(json["expected"], "ZVATKW3VHMFG27DY"); + assert_eq!(json["actual"], "AAAAAAAAAAAAAAAA"); + assert!( + json["help"].as_str().is_some(), + "Auth arm must carry help through the canonical envelope, got: {json}" + ); + + // Non-Auth variant routes through `From<DeviceClientError>` to the + // canonical `Store` error: same `STORE_ERROR` code, and no help + // (StoreError carries none). + let err = device_client_to_napi_error(DeviceClientError::Profile( + stack_profile::ProfileError::HomeDirNotFound, + )); + let json = assertions::failure_json(&err); + assert_eq!(json["type"], "STORE_ERROR"); + assert!(json["message"].as_str().is_some()); + assert!(json.get("help").is_none()); + } + } + + // The `baseUrl` override parsing (empty/absent/valid/malformed semantics) + // lives on `OidcFederationStrategyBuilder::maybe_base_url` in the core + // `stack-auth` crate and is unit-tested there. The factory callbacks are + // `ThreadsafeFunction`s, so these factories can't be driven from a Rust + // unit test — but they *are* exercised end-to-end through the napi seam by + // the vitest suite (`__tests__/oidc-federation-strategy.test.ts`), which + // covers the `baseUrl` override winning over `CS_CTS_HOST`, the + // `INVALID_URL` rejection through the factory, and empty-as-absent. + + // --- Device code result --- + // + // `start_paused = true` creates a tokio runtime where the internal clock + // is paused. Timer operations like `tokio::time::sleep` advance the clock + // instantly instead of waiting in real-time. This matters because + // `poll_for_token` sleeps 5 seconds between each poll — without paused + // time these tests would take 5+ real seconds each. I/O (HTTP requests + // to the mock server) still works normally. + + mod device_code_result { + use super::*; + + mod given_pending_device_code { + use super::*; + + #[tokio::test] + async fn exposes_getters() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + let server = start_server(mocks).await; + + let result = begin_result(&server, &dir).await; + + assert_eq!( + result.user_code(), + "ABCD-EFGH", + "user_code should match device code response" + ); + assert_eq!( + result.verification_uri(), + "http://example.com/activate", + "verification_uri should match device code response" + ); + assert_eq!( + result.verification_uri_complete(), + "http://example.com/activate?user_code=ABCD-EFGH", + "verification_uri_complete should include user code" + ); + assert_eq!( + result.expires_in(), + 900.0, + "expires_in should match device code response" + ); + } + + mod given_successful_token_exchange { + use super::*; + + #[tokio::test(start_paused = true)] + async fn returns_expiry_metadata() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server, &dir).await; + let token = result.poll_for_token().await.unwrap(); + + assert!( + token.expires_in >= 3598.0 && token.expires_in <= 3600.0, + "expires_in should be ~3600, got: {}", + token.expires_in + ); + assert!( + token.expires_at > 0.0, + "expires_at should be a positive epoch timestamp" + ); + } + } + + mod given_access_denied { + use super::*; + + #[tokio::test(start_paused = true)] + async fn returns_access_denied_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("access_denied")); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server, &dir).await; + let err = result.poll_for_token().await.unwrap_err(); + + assertions::has_error_code(&err, "ACCESS_DENIED"); + } + } + + mod given_expired_token { + use super::*; + + #[tokio::test(start_paused = true)] + async fn returns_expired_token_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("expired_token")); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server, &dir).await; + let err = result.poll_for_token().await.unwrap_err(); + + assertions::has_error_code(&err, "EXPIRED_TOKEN"); + } + } + + mod given_invalid_grant { + use super::*; + + #[tokio::test(start_paused = true)] + async fn returns_invalid_grant_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server, &dir).await; + let err = result.poll_for_token().await.unwrap_err(); + + assertions::has_error_code(&err, "INVALID_GRANT"); + } + } + + mod given_invalid_client { + use super::*; + + #[tokio::test(start_paused = true)] + async fn returns_invalid_client_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("invalid_client")); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server, &dir).await; + let err = result.poll_for_token().await.unwrap_err(); + + assertions::has_error_code(&err, "INVALID_CLIENT"); + } + } + + mod given_consumed_handle { + use super::*; + + async fn consumed_result(server: &MockServer, dir: &TempDir) -> DeviceCodeResult { + let result = begin_result(server, dir).await; + result.poll_for_token().await.unwrap(); + result + } + + #[tokio::test(start_paused = true)] + async fn poll_for_token_returns_consumed_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let result = consumed_result(&server, &dir).await; + let err = result.poll_for_token().await.unwrap_err(); + + assertions::has_error_code(&err, "ALREADY_CONSUMED"); + } + + #[tokio::test(start_paused = true)] + async fn open_in_browser_returns_consumed_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let result = consumed_result(&server, &dir).await; + let err = result.open_in_browser().unwrap_err(); + + assertions::has_error_code(&err, "ALREADY_CONSUMED"); + } + } + } + + mod given_invalid_region { + use super::*; + + #[tokio::test] + async fn returns_invalid_region_error() { + let err = + begin_device_code_flow("not-a-region".to_string(), "test-client".to_string()) + .await + .unwrap_err(); + + assertions::has_error_code(&err, "INVALID_REGION"); + } + } + } + + // --- token_result_from --- + + mod token_result { + use super::*; + + mod given_valid_jwt { + use super::*; + + #[test] + fn includes_bearer_token_and_claims() { + let service_token = + make_service_token("https://cts.example.com/", "https://zerokms.example.com/"); + let result = token_result_from(service_token).unwrap(); + + assert!(!result.token.is_empty(), "token string should not be empty"); + assert_eq!( + result.subject, "CS|test-user", + "subject should match JWT sub claim" + ); + assert_eq!( + result.workspace_id, "ZVATKW3VHMFG27DY", + "workspace_id should match JWT workspace claim" + ); + assert_eq!( + result.issuer, "https://cts.example.com/", + "issuer should match JWT iss claim" + ); + assert_eq!( + result.services.get("zerokms").map(String::as_str), + Some("https://zerokms.example.com/"), + "services should include zerokms endpoint" + ); + } + } + + mod given_non_jwt { + use super::*; + + #[test] + fn returns_invalid_token_error() { + let token = ServiceToken::new(stack_auth::SecretToken::new("not-a-jwt")); + let err = token_result_from(token).unwrap_err(); + + assertions::has_error_code(&err, "INVALID_TOKEN"); + } + } + } + + // --- Strategy factories --- + + mod auto_strategy_detect { + use super::*; + + mod given_access_key_without_crn { + use super::*; + + #[test] + fn returns_missing_workspace_crn_error() { + let saved_key = std::env::var("CS_CLIENT_ACCESS_KEY").ok(); + let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok(); + std::env::remove_var("CS_CLIENT_ACCESS_KEY"); + std::env::remove_var("CS_WORKSPACE_CRN"); + + let err = expect_err(AutoStrategy::detect(Some(AutoStrategyOptions { + access_key: Some("CSAKtestKeyId.testKeySecret".to_string()), + workspace_crn: None, + }))); + + if let Some(val) = saved_key { + std::env::set_var("CS_CLIENT_ACCESS_KEY", val); + } + if let Some(val) = saved_crn { + std::env::set_var("CS_WORKSPACE_CRN", val); + } + + assertions::has_error_code(&err, "MISSING_WORKSPACE_CRN"); + } + } + + mod given_invalid_crn { + use super::*; + + #[test] + fn returns_invalid_crn_error() { + let err = expect_err(AutoStrategy::detect(Some(AutoStrategyOptions { + access_key: Some("CSAKtestKeyId.testKeySecret".to_string()), + workspace_crn: Some("not-a-crn".to_string()), + }))); + + assertions::has_error_code(&err, "INVALID_CRN"); + } + } + + /// Happy path: explicit access key + explicit valid CRN constructs + /// an `AutoStrategy` (specifically the `AccessKey` variant). The + /// existing tests only cover the error paths, so a regression in + /// the napi → `AutoStrategy::builder` plumbing (e.g. dropping the + /// CRN before `detect()`) would slide through. + mod given_valid_access_key_and_crn { + use super::*; + + #[test] + fn constructs_strategy_successfully() { + let result = AutoStrategy::detect(Some(AutoStrategyOptions { + access_key: Some("CSAKtestKeyId.testKeySecret".to_string()), + workspace_crn: Some("crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".to_string()), + })); + + assert!( + result.is_ok(), + "valid access key + CRN should construct an AutoStrategy", + ); + } + } + } + + mod access_key_strategy_create { + use super::*; + + // A syntactically valid CRN to use when the test wants to exercise a + // *later* failure path (e.g. invalid access key). Workspace ID is + // arbitrary — these tests never reach the workspace-verification step. + const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; + + mod given_invalid_crn { + use super::*; + + #[test] + fn returns_invalid_crn_error() { + let err = expect_err(AccessKeyStrategy::create( + "not-a-crn".to_string(), + "CSAKid.secret".to_string(), + )); + + assertions::has_error_code(&err, "INVALID_CRN"); + } + } + + mod given_invalid_key { + use super::*; + + #[test] + fn returns_invalid_access_key_error() { + let err = expect_err(AccessKeyStrategy::create( + VALID_CRN.to_string(), + "not-a-valid-key".to_string(), + )); + + assertions::has_error_code(&err, "INVALID_ACCESS_KEY"); + } + } + + /// Happy path: valid CRN + valid access key constructs a strategy. + /// The wasm bindings have an equivalent test + /// (`access_key_strategy_accepts_valid_inputs`); the napi seam + /// needs the same guard so a future regression in + /// `AccessKeyStrategy::create` (e.g. always returning an error) is + /// caught. + mod given_valid_inputs { + use super::*; + + #[test] + fn constructs_strategy_successfully() { + let result = AccessKeyStrategy::create( + VALID_CRN.to_string(), + "CSAKtestKeyId.testKeySecret".to_string(), + ); + + assert!( + result.is_ok(), + "valid CRN + access key should construct an AccessKeyStrategy", + ); + } + } + + /// End-to-end coverage that the `WorkspaceMismatch` error variant + /// surfaces through the napi boundary as `WORKSPACE_MISMATCH` — + /// the underlying Rust check is covered in + /// `stack_auth::access_key_strategy`, but the FFI mapping has its + /// own regression risk (the `error_code` match in this crate). + mod given_token_workspace_mismatch { + use super::*; + + // Drives the wrapper's inner field directly because the public + // `AccessKeyStrategy::create` factory doesn't expose a base-URL + // override. The base-URL override lives behind the `test-utils` + // feature on `stack-auth` and isn't part of the napi surface. + fn build_strategy_against( + server: &MockServer, + crn_workspace: &str, + ) -> AccessKeyStrategy { + let crn: cts_common::Crn = format!("crn:ap-southeast-2.aws:{crn_workspace}") + .parse() + .unwrap(); + let key: stack_auth::AccessKey = "CSAKtestKeyId.testKeySecret".parse().unwrap(); + let inner = stack_auth::AccessKeyStrategy::builder(crn, key) + .base_url(server.url("")) + .build() + .unwrap(); + AccessKeyStrategy { inner } + } + + #[tokio::test] + async fn get_token_returns_workspace_mismatch_error() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const CRN_WS: &str = "ZVATKW3VHMFG27DY"; + + let jwt = jwt_with_workspace(TOKEN_WS); + let mut mocks = MockSet::new(); + mocks.mock(move |when, then| { + when.post().path("/api/authorise"); + then.json(serde_json::json!({ + "accessToken": jwt, + "expiry": 3600, + })); + }); + let server = start_server(mocks).await; + + let strategy = build_strategy_against(&server, CRN_WS); + let err = strategy.get_token().await.unwrap_err(); + + assertions::has_error_code(&err, "WORKSPACE_MISMATCH"); + } + } + } +} diff --git a/languages/typescript/packages/auth/stack-auth-node.js b/languages/typescript/packages/auth/stack-auth-node.js new file mode 100644 index 000000000..b09b99859 --- /dev/null +++ b/languages/typescript/packages/auth/stack-auth-node.js @@ -0,0 +1,75 @@ +/* eslint-disable no-console */ +// Native binding loader for @cipherstash/auth +// Resolves the correct platform-specific optional dependency package. + +const { platform, arch } = process; + +function isMusl() { + try { + // If dlopen is available, check if the libc is musl + const report = + typeof process.report?.getReport === "function" + ? process.report.getReport() + : null; + if (report && typeof report === "object" && report.sharedObjects) { + return report.sharedObjects.some((s) => s.includes("musl")); + } + } catch (_) { + // Fallback: check if /usr/bin/ldd mentions musl + } + try { + const { execSync } = require("node:child_process"); + return execSync("ldd --version 2>&1", { encoding: "utf8" }).includes( + "musl", + ); + } catch (_) { + return false; + } +} + +const platforms = { + "darwin-x64": "@cipherstash/auth-darwin-x64", + "darwin-arm64": "@cipherstash/auth-darwin-arm64", + "linux-x64-gnu": "@cipherstash/auth-linux-x64-gnu", + "linux-x64-musl": "@cipherstash/auth-linux-x64-musl", + "linux-arm64-gnu": "@cipherstash/auth-linux-arm64-gnu", + "win32-x64-msvc": "@cipherstash/auth-win32-x64-msvc", +}; + +function loadBinding() { + let key = `${platform}-${arch}`; + + if (platform === "linux") { + key += isMusl() ? "-musl" : "-gnu"; + } else if (platform === "win32") { + key += "-msvc"; + } + + const pkg = platforms[key]; + if (!pkg) { + throw new Error( + `Unsupported platform: ${platform}-${arch}. ` + + `@cipherstash/auth supports: ${Object.keys(platforms).join(", ")}`, + ); + } + + // Prefer a local .node binary (local development / napi build) so that + // locally-built features (e.g. test-utils) take priority over a published + // platform package that may have been installed alongside it. + try { + return require("./stack-auth-node.node"); + } catch (_) {} + + // Fall back to the platform-specific optional dependency (production / npm install) + try { + return require(pkg); + } catch (_) {} + + throw new Error( + `Failed to load native binding for ${platform}-${arch}. ` + + `Ensure the optional dependency "${pkg}" is installed, ` + + `or run "napi build" for local development.`, + ); +} + +module.exports = loadBinding(); diff --git a/languages/typescript/packages/auth/tsconfig.json b/languages/typescript/packages/auth/tsconfig.json new file mode 100644 index 000000000..eb9863389 --- /dev/null +++ b/languages/typescript/packages/auth/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["__tests__/**/*.ts", "index.d.ts"] +} diff --git a/languages/typescript/packages/auth/vitest.config.ts b/languages/typescript/packages/auth/vitest.config.ts new file mode 100644 index 000000000..b00cfdaf5 --- /dev/null +++ b/languages/typescript/packages/auth/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + testTimeout: 30_000, + }, +}) diff --git a/languages/typescript/packages/auth/wasm-inline.d.ts b/languages/typescript/packages/auth/wasm-inline.d.ts new file mode 100644 index 000000000..efc62a066 --- /dev/null +++ b/languages/typescript/packages/auth/wasm-inline.d.ts @@ -0,0 +1,198 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* + * Public TS surface for the `/wasm-inline` entry — the slick wrapper around + * the raw wasm-bindgen-generated bindings. Consumers see this; the raw + * `createWithStore(crn, key, loadFn, saveFn)` shape stays internal. + * + * Every fallible operation returns a `@byteslice/result` `Result<T, AuthFailure>` + * (`{ data }` on success, `{ failure }` on error) assembled by the wrapper in + * `wasm-inline.mjs` from the structured error the wasm layer attaches — so + * consumers write `if (result.failure) …` and never `try/catch`. (The lower- + * level `/wasm` entry still throws; see `wasm-types.d.ts`.) + */ + +import type { Result } from "@byteslice/result"; + +export type { TokenResult } from "./wasm-types.d.ts"; + +/** Fields present on every {@link AuthFailure}. */ +interface FailureBase { + /** The live `Error` from the wasm boundary, with `.message`/`.code`. */ + error: Error; + /** Actionable diagnostic guidance, when the error carries it. */ + help?: string; + /** A URL with more detail, when the error carries it. */ + url?: string; +} + +/** + * A domain failure returned in the `failure` arm of a `Result`. Discriminated + * by `type`; narrow to access per-variant payload (e.g. `WORKSPACE_MISMATCH`'s + * `expected`/`actual`). This is the full code set; the wasm strategies emit a + * subset (no device-flow or filesystem-store codes). + */ +export type AuthFailure = + | (FailureBase & { type: "REQUEST_ERROR" }) + | (FailureBase & { type: "ACCESS_DENIED" }) + | (FailureBase & { type: "EXPIRED_TOKEN" }) + | (FailureBase & { type: "INVALID_GRANT" }) + | (FailureBase & { type: "INVALID_CLIENT" }) + | (FailureBase & { type: "INVALID_URL" }) + | (FailureBase & { type: "INVALID_REGION" }) + | (FailureBase & { type: "INVALID_TOKEN" }) + | (FailureBase & { type: "USAGE_LIMIT_EXCEEDED" }) + | (FailureBase & { type: "ORG_NOT_PROVISIONED" }) + | (FailureBase & { type: "SERVER_ERROR" }) + | (FailureBase & { type: "NOT_AUTHENTICATED" }) + | (FailureBase & { type: "MISSING_WORKSPACE_CRN" }) + | (FailureBase & { type: "INVALID_ACCESS_KEY" }) + | (FailureBase & { type: "INVALID_CRN" }) + | (FailureBase & { type: "WORKSPACE_MISMATCH"; expected: string; actual: string }) + | (FailureBase & { type: "INVALID_WORKSPACE_ID" }) + | (FailureBase & { type: "ALREADY_CONSUMED" }) + | (FailureBase & { type: "INTERNAL_ERROR" }) + | (FailureBase & { type: "CUSTOM" }) + | (FailureBase & { type: "STORE_ERROR" }); + +/** The machine-readable discriminant carried by every {@link AuthFailure}. */ +export type AuthErrorCode = AuthFailure["type"]; + +/** The resolved value of a `getToken()` call. */ +export type GetTokenResult = Result< + import("./wasm-types.d.ts").TokenResult, + AuthFailure +>; + +/** + * Pluggable persistent cache for service tokens. Pair with the + * `cookieStore` helper from `@cipherstash/auth/cookies` to back the + * strategy with HTTP-only cookies in Edge / Workers / Bun / Deno / Node + * App Router runtimes — or hand-roll your own for KV, Redis, etc. + * + * Both methods are best-effort. `load` returning `null` / `undefined` is + * treated as "cache miss" and falls through to fresh authentication. + * Rejections in either callback are logged via `console.warn` and + * otherwise ignored. + */ +export interface TokenStore { + load(): Promise<string | null | undefined>; + save(json: string): Promise<void>; +} + +/** Options accepted by {@link AccessKeyStrategy.create}. */ +export interface AccessKeyStrategyOptions { + /** + * External persistence. Consulted on cold start before issuing any HTTP + * request, and written to after every successful refresh / initial auth. + * Use to share a service-token cache across short-lived strategy + * instances (one per Edge invocation, one per worker, etc). + */ + store?: TokenStore; +} + +/** + * An auth strategy that uses a static access key for service-to-service + * or CI/CD authentication, scoped to a single workspace identified by a + * CRN. The region is derived from the CRN, so there's no separate + * `region` argument and no chance of the strategy operating against a + * region the caller didn't expect. + * + * Every issued token's `workspace` JWT claim is verified against the + * CRN's workspace ID. A mismatch fails the `getToken()` call with an + * `AuthError` whose `code` is `"WORKSPACE_MISMATCH"` — the strategy + * never silently lets a multi-workspace access key operate on the + * wrong workspace. + */ +export declare class AccessKeyStrategy { + private constructor(); + /** + * Capability flag — `false` for this ambient strategy: the credential is a + * static value, so `getToken()` self-refreshes and can be driven from any + * context. (Contrast {@link OidcFederationStrategy.requiresFederation}.) + */ + readonly requiresFederation: false; + /** + * Create a new `AccessKeyStrategy` for the given workspace CRN and + * access key. + * + * The CRN format is `crn:<region>:<workspace-id>` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from + * the CRN and used for service discovery; the workspace ID is used + * to verify every issued token belongs to the right workspace. + * + * Pass `options.store` to back the strategy with a persistent cache — + * see {@link TokenStore} and the + * {@link https://www.npmjs.com/package/@cipherstash/auth | `@cipherstash/auth/cookies`} + * helper. + */ + static create( + workspaceCrn: string, + accessKey: string, + options?: AccessKeyStrategyOptions, + ): Result<AccessKeyStrategy, AuthFailure>; + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise<GetTokenResult>; + /** Release the underlying wasm resources. */ + free(): void; +} + +/** + * Supplies the *current* third-party OIDC JWT to federate. Called on every + * federation — initial auth and every re-federation after expiry — so it + * should return a live token each time (e.g. `() => clerk.session.getToken()`). + */ +export type OidcProvider = () => string | Promise<string>; + +/** Options accepted by {@link OidcFederationStrategy.create}. */ +export interface OidcFederationStrategyOptions { + /** + * External persistence for the federated CTS token — see + * {@link AccessKeyStrategyOptions.store}. + */ + store?: TokenStore; + /** + * Pin this strategy to a specific CTS host — e.g. a self-hosted CTS or a + * local mock auth server — overriding region service discovery. Scoped to + * this strategy alone. In wasm there is no `CS_CTS_HOST` env fallback, so + * this is the only way to target a host other than the region-discovered one. + */ + baseUrl?: string; +} + +/** + * An auth strategy that federates a third-party OIDC JWT (Clerk, Supabase, …) + * into a CipherStash CTS service token via `/api/authorise`. + */ +export declare class OidcFederationStrategy { + private constructor(); + /** + * Capability flag — `true` for this federated strategy: the third-party JWT + * and token cache are request-scoped, so federation must happen in scope. + * Consumers should read a warmed token (`@cipherstash/auth/next`) rather than + * drive `getToken()` from a detached context. + */ + readonly requiresFederation: true; + /** + * Create an `OidcFederationStrategy` for the given workspace CRN. + * + * The CRN format is `crn:<region>:<workspace-id>` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from the + * CRN and used for service discovery; the workspace ID is used to verify + * every federated token belongs to the right workspace. + * + * `getJwt` must return the current third-party OIDC JWT (it is re-invoked + * on every re-federation). Pass `options.store` to back the strategy with a + * persistent cache — see {@link TokenStore}. + */ + static create( + workspaceCrn: string, + getJwt: OidcProvider, + options?: OidcFederationStrategyOptions, + ): Result<OidcFederationStrategy, AuthFailure>; + /** Retrieve a valid CTS service token, federating or re-federating as needed. */ + getToken(): Promise<GetTokenResult>; + /** Release the underlying wasm resources. */ + free(): void; +} diff --git a/languages/typescript/packages/auth/wasm-inline.mjs b/languages/typescript/packages/auth/wasm-inline.mjs new file mode 100644 index 000000000..749a2e200 --- /dev/null +++ b/languages/typescript/packages/auth/wasm-inline.mjs @@ -0,0 +1,179 @@ +/* @ts-self-types="./wasm-inline.d.ts" */ + +// Slick wrapper around the wasm-bindgen-generated inline-bytes shim. The raw +// `createWithStore(workspaceCrn, key, loadFn, saveFn)` factory below is +// replaced here with a single `create(workspaceCrn, key, { store })` shape +// — easier to extend with future options (lifecycle hooks, custom logging, +// etc.) without breaking callers, and matches the options-object pattern +// most modern JS APIs use. + +import { + AccessKeyStrategy as RawAccessKeyStrategy, + OidcFederationStrategy as RawOidcFederationStrategy, +} from "./wasm/stack_auth_wasm_inline.js"; + +/** @typedef {{ load(): Promise<string | null | undefined>; save(json: string): Promise<void> }} TokenStore */ +/** @typedef {{ store?: TokenStore }} AccessKeyStrategyOptions */ +/** @typedef {() => string | Promise<string>} OidcProvider */ +/** @typedef {{ store?: TokenStore; baseUrl?: string }} OidcFederationStrategyOptions */ + +// Convert a thrown/rejected wasm error into a `Result` `failure`. The wasm +// binding attaches the serialized `AuthError` as an `__authFailure` object on +// the thrown `Error`; we reuse that `Error` as the live `failure.error`. +// Anything without the brand is a genuine panic and is re-thrown. +function toFailure(err) { + const details = err && err.__authFailure; + if (!details || typeof details.type !== "string") throw err; + const { type, help, url, ...payload } = details; + // `payload` still carries `message`; drop it from the spread fields. + delete payload.message; + // Spread payload first so the fixed `type`/`error` keys always win, even if a + // future payload field collides with one of them. + const failure = { ...payload, type, error: err }; + // Mirror help/url onto both the failure and the live Error, matching the napi + // seam (index.js) — loggers that only see `failure.error` still get the hint. + if (help !== undefined) { + err.help = help; + failure.help = help; + } + if (url !== undefined) { + err.url = url; + failure.url = url; + } + return { failure }; +} + +// Mirror index.js's `wrapAsync`: a synchronous throw from the inner `getToken` +// (e.g. calling it after `free()` — "null pointer passed to rust") becomes a +// rejection, so a Promise-returning method never throws synchronously. +function settleGetToken(inner) { + try { + return inner.getToken().then((data) => ({ data }), toFailure); + } catch (err) { + return Promise.reject(err); + } +} + +export class AccessKeyStrategy { + #inner; + + // Ambient strategy: the credential is a static value readable anywhere, so + // `getToken()` self-refreshes and consumers can drive it directly. + requiresFederation = false; + + /** @param {RawAccessKeyStrategy} inner */ + constructor(inner) { + this.#inner = inner; + } + + /** + * @param {string} workspaceCrn + * @param {string} accessKey + * @param {AccessKeyStrategyOptions} [options] + * @returns {import("@byteslice/result").Result<AccessKeyStrategy, import("./wasm-inline.d.ts").AuthFailure>} + */ + static create(workspaceCrn, accessKey, options) { + try { + const store = options?.store; + if (store) { + // Wrap the user's `load` / `save` so the wasm binding always sees + // Promise-returning functions even if the caller passed sync ones — + // `js_sys::Promise::from` on the wasm side casts the return value as + // a Promise unconditionally, so sync values would otherwise reject. + const load = () => Promise.resolve(store.load()); + const save = (/** @type {string} */ json) => + Promise.resolve(store.save(json)); + return { + data: new AccessKeyStrategy( + RawAccessKeyStrategy.createWithStore( + workspaceCrn, + accessKey, + load, + save, + ), + ), + }; + } + return { + data: new AccessKeyStrategy( + RawAccessKeyStrategy.create(workspaceCrn, accessKey), + ), + }; + } catch (err) { + return toFailure(err); + } + } + + /** @returns {Promise<import("./wasm-inline.d.ts").GetTokenResult>} */ + getToken() { + return settleGetToken(this.#inner); + } + + free() { + this.#inner.free(); + } +} + +export class OidcFederationStrategy { + #inner; + + // Federated strategy: the third-party JWT lives in request scope and the + // cache is request-scoped, so federation must happen in scope. Consumers + // should read a warmed token (see `@cipherstash/auth/next`) rather than drive + // `getToken()` from a detached context. + requiresFederation = true; + + /** @param {RawOidcFederationStrategy} inner */ + constructor(inner) { + this.#inner = inner; + } + + /** + * @param {string} workspaceCrn + * @param {OidcProvider} getJwt + * @param {OidcFederationStrategyOptions} [options] + * @returns {import("@byteslice/result").Result<OidcFederationStrategy, import("./wasm-inline.d.ts").AuthFailure>} + */ + static create(workspaceCrn, getJwt, options) { + try { + // Wrap `getJwt` so the wasm binding always sees a Promise-returning + // function even if the caller passed a sync one — see the note in + // `AccessKeyStrategy.create`. + const jwt = () => Promise.resolve(getJwt()); + const store = options?.store; + const baseUrl = options?.baseUrl; + if (store) { + const load = () => Promise.resolve(store.load()); + const save = (/** @type {string} */ json) => + Promise.resolve(store.save(json)); + return { + data: new OidcFederationStrategy( + RawOidcFederationStrategy.createWithStore( + workspaceCrn, + jwt, + load, + save, + baseUrl, + ), + ), + }; + } + return { + data: new OidcFederationStrategy( + RawOidcFederationStrategy.create(workspaceCrn, jwt, baseUrl), + ), + }; + } catch (err) { + return toFailure(err); + } + } + + /** @returns {Promise<import("./wasm-inline.d.ts").GetTokenResult>} */ + getToken() { + return settleGetToken(this.#inner); + } + + free() { + this.#inner.free(); + } +} diff --git a/languages/typescript/packages/auth/wasm-types.d.ts b/languages/typescript/packages/auth/wasm-types.d.ts new file mode 100644 index 000000000..b60e054f5 --- /dev/null +++ b/languages/typescript/packages/auth/wasm-types.d.ts @@ -0,0 +1,143 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* + * Hand-typed overlay for the raw wasm-bindgen output behind the `/wasm` + * sub-path. Most consumers should reach for the slick wrapper at + * `/wasm-inline` (see `wasm-inline.d.ts`) which exposes the options-object + * API and the `cookieStore`-friendly shape. This file documents the + * lower-level surface: a single `create(workspaceCrn, accessKey)` factory + * with no built-in store wiring. + * + * The wasm-bindgen build emits `wasm/stack_auth_wasm.d.ts` automatically, + * but its types are looser than we want (`Promise<any>` for `getToken`, + * leaks internal `wasm-streams` types like `IntoUnderlyingByteSource` that + * arrive transitively via reqwest's wasm32 fetch backend). This file is + * the `types` entry for `/wasm` — at runtime callers load the + * auto-generated `.js` shim, but the types they see come from here. + * + * The Node entry uses `index.d.ts`, which exposes the full surface + * (including filesystem- and browser-backed features like the device-code + * flow and profile-store loading) that doesn't compile to wasm32. + * OAuth-based strategies on wasm (`DeviceSessionStrategy`, `AutoStrategy`) are + * deferred to a follow-up — see the Layer 3.5 notes in `wasm-analysis.md`. + */ + +/** Error codes attached to errors thrown by this package. */ +export type AuthErrorCode = + | 'REQUEST_ERROR' + | 'ACCESS_DENIED' + | 'INVALID_GRANT' + | 'INVALID_CLIENT' + | 'INVALID_URL' + | 'INVALID_REGION' + | 'INVALID_CRN' + | 'WORKSPACE_MISMATCH' + | 'INVALID_WORKSPACE_ID' + | 'MISSING_WORKSPACE_CRN' + | 'NOT_AUTHENTICATED' + | 'EXPIRED_TOKEN' + | 'INVALID_ACCESS_KEY' + | 'INVALID_TOKEN' + | 'USAGE_LIMIT_EXCEEDED' + | 'ORG_NOT_PROVISIONED' + | 'SERVER_ERROR' + | 'ALREADY_CONSUMED' + | 'INTERNAL_ERROR' + | 'CUSTOM' + +/** An error thrown by this package, enriched with a machine-readable `.code`. */ +export interface AuthError extends Error { + code: AuthErrorCode +} + +/** + * The result of a successful `getToken()` call. + * + * Contains the bearer credential and decoded JWT claims for service discovery. + */ +export interface TokenResult { + /** The bearer token string (used as `Authorization: Bearer <token>`). */ + token: string + /** The subject claim from the JWT (e.g. `"CS|auth0|user123"` or `"CS|CSAKkeyId"`). */ + subject: string + /** The workspace identifier from the JWT. */ + workspaceId: string + /** The issuer URL from the JWT `iss` claim (i.e. the CTS host). */ + issuer: string + /** Service endpoint URLs from the JWT `services` claim (e.g. `{ zerokms: "https://..." }`). */ + services: Record<string, string> +} + +/** + * An auth strategy that uses a static access key for service-to-service + * or CI/CD authentication, scoped to a single workspace identified by a + * CRN. Region is derived from the CRN. Every issued token's `workspace` + * JWT claim is verified against the CRN; mismatch fails the `getToken()` + * call with a `WORKSPACE_MISMATCH` error. + * + * This is the raw bundler-target binding — consumers wanting the + * options-object / cookie-store-friendly shape should import from + * `/wasm-inline` instead. + */ +export declare class AccessKeyStrategy { + private constructor() + /** + * Create a new `AccessKeyStrategy` for the given workspace CRN and + * access key. + * + * The CRN format is `crn:<region>:<workspace-id>` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). + */ + static create(workspaceCrn: string, accessKey: string): AccessKeyStrategy + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise<TokenResult> + /** Release the underlying wasm resources. */ + free(): void +} + +/** + * Federates a third-party OIDC JWT (Clerk, Supabase, …) into a CipherStash + * CTS service token. This is the raw bundler-target binding — consumers + * wanting the options-object / cookie-store-friendly shape should import from + * `/wasm-inline` instead. + * + * `getJwt` / `loadToken` / `saveToken` are JS callbacks returning Promises. + */ +export declare class OidcFederationStrategy { + private constructor() + /** + * Create an `OidcFederationStrategy` for the given workspace CRN. The CRN + * format is `crn:<region>:<workspace-id>` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). + * + * `baseUrl`, when supplied, pins this strategy to a specific CTS host — + * e.g. a self-hosted CTS or a local mock auth server — overriding region + * service discovery, scoped to this strategy alone. + */ + static create( + workspaceCrn: string, + getJwt: () => Promise<string>, + baseUrl?: string | undefined | null, + ): OidcFederationStrategy + /** + * Create an `OidcFederationStrategy` backed by external token-store + * callbacks. Takes the same `workspaceCrn` as {@link create} (region for + * service discovery, workspace ID for verification) plus `loadToken` / + * `saveToken` to persist the federated CTS token across requests. + * + * `baseUrl` behaves as in {@link create} — an explicit, strategy-scoped CTS + * host that overrides region service discovery. + */ + static createWithStore( + workspaceCrn: string, + getJwt: () => Promise<string>, + loadToken: () => Promise<string | null | undefined>, + saveToken: (json: string) => Promise<void>, + baseUrl?: string | undefined | null, + ): OidcFederationStrategy + /** Retrieve a valid CTS service token, federating or re-federating as needed. */ + getToken(): Promise<TokenResult> + /** Release the underlying wasm resources. */ + free(): void +} diff --git a/languages/typescript/packages/profile/.gitignore b/languages/typescript/packages/profile/.gitignore new file mode 100644 index 000000000..07a825bc4 --- /dev/null +++ b/languages/typescript/packages/profile/.gitignore @@ -0,0 +1,4 @@ +target/ +node_modules/ +*.node +npm/*/*.node diff --git a/languages/typescript/packages/profile/CHANGELOG.md b/languages/typescript/packages/profile/CHANGELOG.md new file mode 100644 index 000000000..da7b2a105 --- /dev/null +++ b/languages/typescript/packages/profile/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## 0.35.0 + +### New Features + +- **Multi-workspace profile management** — `ProfileStore` exposes workspace lifecycle operations to Node.js: + ```ts + const { ProfileStore } = require("@cipherstash/profile"); + const store = ProfileStore.resolve(); + + store.setCurrentWorkspace("E4UMRN47WJNSMAKR"); + const workspaces = store.listWorkspaces(); + const wsStore = store.currentWorkspaceStore(); + ``` +- **`ProfileStore.resolve()`** — open the default `~/.cipherstash` profile directory (or `CS_CONFIG_PATH`). +- **`ProfileStore.withDir(path)`** — open a profile store at a custom directory. +- **`setCurrentWorkspace` / `currentWorkspace` / `clearCurrentWorkspace`** — manage the active workspace. +- **`listWorkspaces`** — enumerate workspace IDs with local profile data. +- **`workspaceStore(id)` / `currentWorkspaceStore()`** — get a store scoped to a workspace subdirectory. +- **Error enrichment** — all errors include a machine-readable `.code` property (e.g. `NO_CURRENT_WORKSPACE`). diff --git a/languages/typescript/packages/profile/Cargo.toml b/languages/typescript/packages/profile/Cargo.toml new file mode 100644 index 000000000..ea96fa83c --- /dev/null +++ b/languages/typescript/packages/profile/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "stack-profile-node" +version.workspace = true +edition.workspace = true +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +stack-profile = { workspace = true } +napi = "2" +napi-derive = "2" + +[build-dependencies] +napi-build = "2" diff --git a/languages/typescript/packages/profile/README.md b/languages/typescript/packages/profile/README.md new file mode 100644 index 000000000..f82736b72 --- /dev/null +++ b/languages/typescript/packages/profile/README.md @@ -0,0 +1,92 @@ +# @cipherstash/profile + +[![npm version](https://img.shields.io/npm/v/@cipherstash/profile?style=for-the-badge)](https://www.npmjs.com/package/@cipherstash/profile) +[![Built by CipherStash](https://raw.githubusercontent.com/cipherstash/meta/refs/heads/main/csbadge.svg)](https://cipherstash.com) + + [Website](https://cipherstash.com) | [Docs](https://cipherstash.com/docs) | [Discord](https://discord.com/invite/5qwXUFb6PB) + +Native Node.js bindings for managing [CipherStash](https://cipherstash.com) workspace profiles. + +Profiles are stored in `~/.cipherstash/` (or the path specified by `CS_CONFIG_PATH`) with per-workspace directories for auth tokens and encryption keys. + +## Installation + +```bash +npm install @cipherstash/profile +``` + +Prebuilt native binaries are included for: + +- macOS (x64, ARM64) +- Linux (x64 glibc, x64 musl, ARM64 glibc) +- Windows (x64) + +## Usage + +```js +const { ProfileStore } = require("@cipherstash/profile"); + +// Open the default profile store (~/.cipherstash) +const store = ProfileStore.resolve(); + +// Set the active workspace +store.setCurrentWorkspace("E4UMRN47WJNSMAKR"); + +// List workspaces with local profile data +const workspaces = store.listWorkspaces(); +console.log(workspaces); // ["E4UMRN47WJNSMAKR", "JBSWY3DPEHPK3PXP"] + +// Get a store scoped to the current workspace +const wsStore = store.currentWorkspaceStore(); +console.log(wsStore.dir); // ~/.cipherstash/workspaces/E4UMRN47WJNSMAKR +``` + +## API + +### `ProfileStore.resolve()` + +Create a profile store at the default location (`~/.cipherstash`), or the path specified by the `CS_CONFIG_PATH` environment variable. + +### `ProfileStore.withDir(dir)` + +Create a profile store rooted at the given directory. + +### Instance methods + +| Method | Description | +|---|---| +| `dir` | The directory path of this profile store | +| `setCurrentWorkspace(id)` | Switch to a workspace. The workspace must already exist on disk (created during login). Throws `WORKSPACE_NOT_FOUND` if not, or `INVALID_WORKSPACE_ID` for malformed IDs. | +| `currentWorkspace()` | Get the current workspace ID (throws if unset) | +| `clearCurrentWorkspace()` | Remove the workspace selection | +| `listWorkspaces()` | List workspace IDs with local profile data | +| `workspaceStore(id)` | Get a store scoped to a specific workspace. Throws `INVALID_WORKSPACE_ID` for malformed IDs. The workspace directory is created on first write. | +| `currentWorkspaceStore()` | Get a store scoped to the current workspace (throws if unset) | + +## Error handling + +Errors thrown by the native module include a machine-readable `.code` property: + +```js +try { + store.currentWorkspace(); +} catch (err) { + console.error(err.code); // "NO_CURRENT_WORKSPACE" + console.error(err.message); // Human-readable description +} +``` + +### Error codes + +| Code | Description | +|---|---| +| `NO_CURRENT_WORKSPACE` | No workspace has been set | +| `INVALID_WORKSPACE_ID` | The workspace ID is not a valid 16-character base32 string | +| `WORKSPACE_NOT_FOUND` | The workspace has no local profile data (not logged in) | +| `NOT_FOUND` | A requested profile file was not found | +| `IO_ERROR` | An I/O error occurred | +| `HOME_DIR_NOT_FOUND` | Could not determine the home directory | + +## License + +See [LICENSE](https://github.com/cipherstash/cipherstash-suite/blob/main/packages/stack-profile/LICENSE). diff --git a/languages/typescript/packages/profile/__tests__/profile-store.test.ts b/languages/typescript/packages/profile/__tests__/profile-store.test.ts new file mode 100644 index 000000000..3f2fe77d5 --- /dev/null +++ b/languages/typescript/packages/profile/__tests__/profile-store.test.ts @@ -0,0 +1,158 @@ +import { existsSync, mkdirSync, mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { beforeEach, describe, expect, it } from 'vitest' +import type { ProfileError, ProfileStore as ProfileStoreType } from '../index' + +const mod = require('../index.js') as typeof import('../index') +const { ProfileStore } = mod + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const WS_A = 'AAAAAAAAAAAAAAAA' +const WS_B = 'BBBBBBBBBBBBBBBB' + +let profileDir: string + +function freshProfileDir(): string { + return mkdtempSync(join(tmpdir(), 'cs-profile-test-')) +} + +function store(): InstanceType<typeof ProfileStoreType> { + return ProfileStore.withDir(profileDir) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ProfileStore', () => { + beforeEach(() => { + profileDir = freshProfileDir() + }) + + describe('resolve', () => { + it('returns a ProfileStore at the default location', () => { + const s = ProfileStore.resolve() + expect(s.dir).toBeTruthy() + }) + }) + + describe('withDir', () => { + it('returns a ProfileStore at the given directory', () => { + const s = ProfileStore.withDir('/tmp/custom') + expect(s.dir).toBe('/tmp/custom') + }) + }) + + describe('given no workspace set', () => { + it('currentWorkspace throws NO_CURRENT_WORKSPACE', () => { + try { + store().currentWorkspace() + expect.unreachable('should have thrown') + } catch (err) { + const profileErr = err as ProfileError + expect(profileErr).toBeInstanceOf(Error) + expect(profileErr.code).toBe('NO_CURRENT_WORKSPACE') + } + }) + + it('currentWorkspaceStore throws NO_CURRENT_WORKSPACE', () => { + try { + store().currentWorkspaceStore() + expect.unreachable('should have thrown') + } catch (err) { + const profileErr = err as ProfileError + expect(profileErr.code).toBe('NO_CURRENT_WORKSPACE') + } + }) + + it('listWorkspaces returns empty array', () => { + expect(store().listWorkspaces()).toEqual([]) + }) + + it('clearCurrentWorkspace succeeds', () => { + expect(() => store().clearCurrentWorkspace()).not.toThrow() + }) + }) + + describe('given workspace set', () => { + beforeEach(() => { + mkdirSync(join(profileDir, 'workspaces', WS_A), { recursive: true }) + store().setCurrentWorkspace(WS_A) + }) + + it('currentWorkspace returns the workspace ID', () => { + expect(store().currentWorkspace()).toBe(WS_A) + }) + + it('currentWorkspaceStore returns a scoped store', () => { + const ws = store().currentWorkspaceStore() + expect(ws.dir).toBe(join(profileDir, 'workspaces', WS_A)) + }) + + it('clearCurrentWorkspace removes the selection', () => { + store().clearCurrentWorkspace() + try { + store().currentWorkspace() + expect.unreachable('should have thrown') + } catch (err) { + expect((err as ProfileError).code).toBe('NO_CURRENT_WORKSPACE') + } + }) + }) + + describe('workspaceStore', () => { + it('returns a store scoped to the workspace directory', () => { + const ws = store().workspaceStore(WS_A) + expect(ws.dir).toBe(join(profileDir, 'workspaces', WS_A)) + }) + + it('throws INVALID_WORKSPACE_ID for bad input', () => { + try { + store().workspaceStore('../escape') + expect.unreachable('should have thrown') + } catch (err) { + expect((err as ProfileError).code).toBe('INVALID_WORKSPACE_ID') + } + }) + }) + + describe('setCurrentWorkspace', () => { + it('throws WORKSPACE_NOT_FOUND for workspace without profile data', () => { + try { + store().setCurrentWorkspace(WS_A) + expect.unreachable('should have thrown') + } catch (err) { + expect((err as ProfileError).code).toBe('WORKSPACE_NOT_FOUND') + } + }) + }) + + describe('given multiple workspaces', () => { + beforeEach(() => { + mkdirSync(join(profileDir, 'workspaces', WS_A), { recursive: true }) + mkdirSync(join(profileDir, 'workspaces', WS_B), { recursive: true }) + store().setCurrentWorkspace(WS_A) + }) + + it('listWorkspaces returns sorted workspace IDs', () => { + expect(store().listWorkspaces()).toEqual([WS_A, WS_B]) + }) + + it('switching workspace changes currentWorkspaceStore', () => { + const s = store() + s.setCurrentWorkspace(WS_A) + expect(s.currentWorkspaceStore().dir).toBe( + join(profileDir, 'workspaces', WS_A), + ) + + s.setCurrentWorkspace(WS_B) + expect(s.currentWorkspaceStore().dir).toBe( + join(profileDir, 'workspaces', WS_B), + ) + }) + }) +}) diff --git a/languages/typescript/packages/profile/build.rs b/languages/typescript/packages/profile/build.rs new file mode 100644 index 000000000..9fc236788 --- /dev/null +++ b/languages/typescript/packages/profile/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/languages/typescript/packages/profile/examples/workspace-management.ts b/languages/typescript/packages/profile/examples/workspace-management.ts new file mode 100644 index 000000000..ae9b9315a --- /dev/null +++ b/languages/typescript/packages/profile/examples/workspace-management.ts @@ -0,0 +1,46 @@ +// Example: Manage workspaces using the profile store. +// +// The `ProfileStore` manages `~/.cipherstash/` and tracks which +// workspace is currently active. Each workspace gets its own +// subdirectory under `workspaces/<id>/` for auth and key data. +// +// Prerequisites: +// 1. Build the native module: npm run build +// 2. Log in with the CLI: stash login +// +// Usage: +// npx tsx examples/workspace-management.ts + +import type { ProfileError } from '../index' +import { ProfileStore } from '../index' + +function main() { + const store = ProfileStore.resolve() + console.log(`Profile directory: ${store.dir}`) + + // List workspaces that have local profile data. + const workspaces = store.listWorkspaces() + console.log(`\nWorkspaces on disk: ${workspaces.length}`) + for (const id of workspaces) { + console.log(` - ${id}`) + } + + // Show the current workspace (if set). + try { + const current = store.currentWorkspace() + console.log(`\nCurrent workspace: ${current}`) + + // Get a store scoped to the current workspace. + const wsStore = store.currentWorkspaceStore() + console.log(`Workspace directory: ${wsStore.dir}`) + } catch (err) { + const profileErr = err as ProfileError + if (profileErr.code === 'NO_CURRENT_WORKSPACE') { + console.log('\nNo current workspace set. Run `stash login` first.') + } else { + throw err + } + } +} + +main() diff --git a/languages/typescript/packages/profile/index.d.ts b/languages/typescript/packages/profile/index.d.ts new file mode 100644 index 000000000..0b7fd4ba3 --- /dev/null +++ b/languages/typescript/packages/profile/index.d.ts @@ -0,0 +1,50 @@ +/* tslint:disable */ +/* eslint-disable */ + +/** Error codes attached to errors thrown by this package. */ +export type ProfileErrorCode = + | "IO_ERROR" + | "JSON_ERROR" + | "HOME_DIR_NOT_FOUND" + | "NOT_FOUND" + | "INVALID_FILENAME" + | "NO_CURRENT_WORKSPACE" + | "INVALID_WORKSPACE_ID" + | "WORKSPACE_NOT_FOUND" + | "UNKNOWN_ERROR"; + +/** An error thrown by this package, enriched with a machine-readable `.code`. */ +export interface ProfileError extends Error { + code: ProfileErrorCode; +} + +/** + * A directory-scoped profile store for managing workspace profiles. + * + * Use `ProfileStore.resolve()` for the default `~/.cipherstash` location, + * or `ProfileStore.withDir(path)` for a custom directory. + */ +export class ProfileStore { + /** Create a profile store at the default location (`~/.cipherstash`). */ + static resolve(): ProfileStore; + /** Create a profile store rooted at the given directory. */ + static withDir(dir: string): ProfileStore; + + /** The directory path of this profile store. */ + get dir(): string; + + /** Set the current workspace. The workspace must already exist on disk (created during login). Throws `WORKSPACE_NOT_FOUND` otherwise. */ + setCurrentWorkspace(workspaceId: string): void; + /** Return the current workspace ID. Throws if no workspace has been set. */ + currentWorkspace(): string; + /** Remove the current workspace selection. */ + clearCurrentWorkspace(): void; + + /** List workspace IDs that have profile data on disk. */ + listWorkspaces(): string[]; + + /** Return a profile store scoped to a specific workspace directory. */ + workspaceStore(workspaceId: string): ProfileStore; + /** Return a profile store scoped to the current workspace. Throws if no workspace has been set. */ + currentWorkspaceStore(): ProfileStore; +} diff --git a/languages/typescript/packages/profile/index.js b/languages/typescript/packages/profile/index.js new file mode 100644 index 000000000..cc6162e65 --- /dev/null +++ b/languages/typescript/packages/profile/index.js @@ -0,0 +1,39 @@ +const native = require('./stack-profile-node.js') + +const CODE_RE = /^([A-Z_]+): / + +function enrichError(err) { + if (err instanceof Error) { + const match = CODE_RE.exec(err.message) + if (match) { + err.code = match[1] + err.message = err.message.slice(match[0].length) + } + } + throw err +} + +function wrapSync(fn) { + return function (...args) { + try { + return fn.apply(this, args) + } catch (err) { + enrichError(err) + } + } +} + +// Wrap ProfileStore methods that can throw +const proto = native.ProfileStore.prototype +proto.setCurrentWorkspace = wrapSync(proto.setCurrentWorkspace) +proto.currentWorkspace = wrapSync(proto.currentWorkspace) +proto.clearCurrentWorkspace = wrapSync(proto.clearCurrentWorkspace) +proto.listWorkspaces = wrapSync(proto.listWorkspaces) +proto.workspaceStore = wrapSync(proto.workspaceStore) +proto.currentWorkspaceStore = wrapSync(proto.currentWorkspaceStore) + +// Wrap factory methods +const origResolve = native.ProfileStore.resolve +native.ProfileStore.resolve = wrapSync(origResolve) + +module.exports = native diff --git a/languages/typescript/packages/profile/native.d.ts b/languages/typescript/packages/profile/native.d.ts new file mode 100644 index 000000000..612a19965 --- /dev/null +++ b/languages/typescript/packages/profile/native.d.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +export declare class ProfileStore { + /** + * Create a profile store at the default location (`~/.cipherstash`), + * or the path specified by the `CS_CONFIG_PATH` environment variable. + */ + static resolve(): ProfileStore + /** Create a profile store rooted at the given directory. */ + static withDir(dir: string): ProfileStore + /** The directory path of this profile store. */ + get dir(): string + /** Set the current workspace. */ + setCurrentWorkspace(workspaceId: string): void + /** + * Return the current workspace ID. + * + * Throws if no workspace has been set. + */ + currentWorkspace(): string + /** Remove the current workspace selection. */ + clearCurrentWorkspace(): void + /** List workspace IDs that have profile data on disk. */ + listWorkspaces(): Array<string> + /** Return a profile store scoped to a specific workspace directory. */ + workspaceStore(workspaceId: string): ProfileStore + /** + * Return a profile store scoped to the current workspace. + * + * Throws if no workspace has been set. + */ + currentWorkspaceStore(): ProfileStore +} diff --git a/languages/typescript/packages/profile/package.json b/languages/typescript/packages/profile/package.json new file mode 100644 index 000000000..671f2a920 --- /dev/null +++ b/languages/typescript/packages/profile/package.json @@ -0,0 +1,46 @@ +{ + "name": "@cipherstash/profile", + "version": "0.35.0", + "private": true, + "main": "index.js", + "types": "index.d.ts", + "napi": { + "name": "stack-profile-node", + "triples": { + "defaults": false, + "additional": [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "x86_64-pc-windows-msvc" + ] + } + }, + "files": [ + "index.js", + "index.d.ts", + "README.md", + "stack-profile-node.js" + ], + "scripts": { + "build:native": "napi build --release --dts native.d.ts", + "build:debug": "napi build --dts native.d.ts", + "test": "vitest run && biome check .", + "test:cargo": "cargo test --locked" + }, + "optionalDependencies": { + "@cipherstash/profile-darwin-x64": "workspace:*", + "@cipherstash/profile-darwin-arm64": "workspace:*", + "@cipherstash/profile-linux-x64-gnu": "workspace:*", + "@cipherstash/profile-linux-arm64-gnu": "workspace:*", + "@cipherstash/profile-linux-x64-musl": "workspace:*", + "@cipherstash/profile-win32-x64-msvc": "workspace:*" + }, + "devDependencies": { + "@napi-rs/cli": "^2", + "vitest": "^3", + "typescript": "^5" + } +} diff --git a/languages/typescript/packages/profile/platforms/darwin-arm64/package.json b/languages/typescript/packages/profile/platforms/darwin-arm64/package.json new file mode 100644 index 000000000..d2216eb15 --- /dev/null +++ b/languages/typescript/packages/profile/platforms/darwin-arm64/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cipherstash/profile-darwin-arm64", + "version": "0.35.0", + "private": true, + "os": ["darwin"], + "cpu": ["arm64"], + "main": "stack-profile-node.darwin-arm64.node", + "files": ["stack-profile-node.darwin-arm64.node"] +} diff --git a/languages/typescript/packages/profile/platforms/darwin-x64/package.json b/languages/typescript/packages/profile/platforms/darwin-x64/package.json new file mode 100644 index 000000000..f710fd79e --- /dev/null +++ b/languages/typescript/packages/profile/platforms/darwin-x64/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cipherstash/profile-darwin-x64", + "version": "0.35.0", + "private": true, + "os": ["darwin"], + "cpu": ["x64"], + "main": "stack-profile-node.darwin-x64.node", + "files": ["stack-profile-node.darwin-x64.node"] +} diff --git a/languages/typescript/packages/profile/platforms/linux-arm64-gnu/package.json b/languages/typescript/packages/profile/platforms/linux-arm64-gnu/package.json new file mode 100644 index 000000000..db8ff9f5e --- /dev/null +++ b/languages/typescript/packages/profile/platforms/linux-arm64-gnu/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cipherstash/profile-linux-arm64-gnu", + "version": "0.35.0", + "private": true, + "os": ["linux"], + "cpu": ["arm64"], + "main": "stack-profile-node.linux-arm64-gnu.node", + "files": ["stack-profile-node.linux-arm64-gnu.node"] +} diff --git a/languages/typescript/packages/profile/platforms/linux-x64-gnu/package.json b/languages/typescript/packages/profile/platforms/linux-x64-gnu/package.json new file mode 100644 index 000000000..b1f321f62 --- /dev/null +++ b/languages/typescript/packages/profile/platforms/linux-x64-gnu/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cipherstash/profile-linux-x64-gnu", + "version": "0.35.0", + "private": true, + "os": ["linux"], + "cpu": ["x64"], + "main": "stack-profile-node.linux-x64-gnu.node", + "files": ["stack-profile-node.linux-x64-gnu.node"] +} diff --git a/languages/typescript/packages/profile/platforms/linux-x64-musl/package.json b/languages/typescript/packages/profile/platforms/linux-x64-musl/package.json new file mode 100644 index 000000000..7517a7b3a --- /dev/null +++ b/languages/typescript/packages/profile/platforms/linux-x64-musl/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cipherstash/profile-linux-x64-musl", + "version": "0.35.0", + "private": true, + "os": ["linux"], + "cpu": ["x64"], + "main": "stack-profile-node.linux-x64-musl.node", + "files": ["stack-profile-node.linux-x64-musl.node"] +} diff --git a/languages/typescript/packages/profile/platforms/win32-x64-msvc/package.json b/languages/typescript/packages/profile/platforms/win32-x64-msvc/package.json new file mode 100644 index 000000000..be6681aba --- /dev/null +++ b/languages/typescript/packages/profile/platforms/win32-x64-msvc/package.json @@ -0,0 +1,9 @@ +{ + "name": "@cipherstash/profile-win32-x64-msvc", + "version": "0.35.0", + "private": true, + "os": ["win32"], + "cpu": ["x64"], + "main": "stack-profile-node.win32-x64-msvc.node", + "files": ["stack-profile-node.win32-x64-msvc.node"] +} diff --git a/languages/typescript/packages/profile/src/lib.rs b/languages/typescript/packages/profile/src/lib.rs new file mode 100644 index 000000000..f7d0b2a36 --- /dev/null +++ b/languages/typescript/packages/profile/src/lib.rs @@ -0,0 +1,109 @@ +use napi::bindgen_prelude::*; +use napi_derive::napi; + +// --------------------------------------------------------------------------- +// Error mapping +// --------------------------------------------------------------------------- + +fn error_code(err: &stack_profile::ProfileError) -> &'static str { + match err { + stack_profile::ProfileError::Io(_) => "IO_ERROR", + stack_profile::ProfileError::Json(_) => "JSON_ERROR", + stack_profile::ProfileError::HomeDirNotFound => "HOME_DIR_NOT_FOUND", + stack_profile::ProfileError::NotFound { .. } => "NOT_FOUND", + stack_profile::ProfileError::InvalidFilename(_) => "INVALID_FILENAME", + stack_profile::ProfileError::NoCurrentWorkspace => "NO_CURRENT_WORKSPACE", + stack_profile::ProfileError::InvalidWorkspaceId(_) => "INVALID_WORKSPACE_ID", + stack_profile::ProfileError::WorkspaceNotFound(_) => "WORKSPACE_NOT_FOUND", + _ => "UNKNOWN_ERROR", + } +} + +fn to_napi_error(err: stack_profile::ProfileError) -> napi::Error { + let code = error_code(&err); + napi::Error::new(Status::GenericFailure, format!("{code}: {err}")) +} + +// --------------------------------------------------------------------------- +// ProfileStore +// --------------------------------------------------------------------------- + +#[napi] +pub struct ProfileStore { + inner: stack_profile::ProfileStore, +} + +#[napi] +impl ProfileStore { + /// Create a profile store at the default location (`~/.cipherstash`), + /// or the path specified by the `CS_CONFIG_PATH` environment variable. + #[napi(factory)] + pub fn resolve() -> Result<Self> { + let inner = stack_profile::ProfileStore::resolve(None).map_err(to_napi_error)?; + Ok(Self { inner }) + } + + /// Create a profile store rooted at the given directory. + #[napi(factory)] + pub fn with_dir(dir: String) -> Self { + Self { + inner: stack_profile::ProfileStore::new(dir), + } + } + + /// The directory path of this profile store. + #[napi(getter)] + pub fn dir(&self) -> String { + self.inner.dir().to_string_lossy().into_owned() + } + + /// Set the current workspace. + #[napi] + pub fn set_current_workspace(&self, workspace_id: String) -> Result<()> { + self.inner + .set_current_workspace(&workspace_id) + .map_err(to_napi_error) + } + + /// Return the current workspace ID. + /// + /// Throws if no workspace has been set. + #[napi] + pub fn current_workspace(&self) -> Result<String> { + self.inner.current_workspace().map_err(to_napi_error) + } + + /// Remove the current workspace selection. + #[napi] + pub fn clear_current_workspace(&self) -> Result<()> { + self.inner.clear_current_workspace().map_err(to_napi_error) + } + + /// List workspace IDs that have profile data on disk. + #[napi] + pub fn list_workspaces(&self) -> Result<Vec<String>> { + self.inner.list_workspaces().map_err(to_napi_error) + } + + /// Return a profile store scoped to a specific workspace directory. + #[napi] + pub fn workspace_store(&self, workspace_id: String) -> Result<ProfileStore> { + let inner = self + .inner + .workspace_store(&workspace_id) + .map_err(to_napi_error)?; + Ok(ProfileStore { inner }) + } + + /// Return a profile store scoped to the current workspace. + /// + /// Throws if no workspace has been set. + #[napi] + pub fn current_workspace_store(&self) -> Result<ProfileStore> { + let inner = self + .inner + .current_workspace_store() + .map_err(to_napi_error)?; + Ok(ProfileStore { inner }) + } +} diff --git a/languages/typescript/packages/profile/stack-profile-node.js b/languages/typescript/packages/profile/stack-profile-node.js new file mode 100644 index 000000000..704b9d89b --- /dev/null +++ b/languages/typescript/packages/profile/stack-profile-node.js @@ -0,0 +1,64 @@ +const { platform, arch } = process + +function isMusl() { + try { + const report = + typeof process.report?.getReport === 'function' + ? process.report.getReport() + : null + if (report && typeof report === 'object' && report.sharedObjects) { + return report.sharedObjects.some((s) => s.includes('musl')) + } + } catch (_) {} + try { + const { execSync } = require('node:child_process') + return execSync('ldd --version 2>&1', { encoding: 'utf8' }).includes('musl') + } catch (_) { + return false + } +} + +const platforms = { + 'darwin-x64': '@cipherstash/profile-darwin-x64', + 'darwin-arm64': '@cipherstash/profile-darwin-arm64', + 'linux-x64-gnu': '@cipherstash/profile-linux-x64-gnu', + 'linux-x64-musl': '@cipherstash/profile-linux-x64-musl', + 'linux-arm64-gnu': '@cipherstash/profile-linux-arm64-gnu', + 'win32-x64-msvc': '@cipherstash/profile-win32-x64-msvc', +} + +function loadBinding() { + let key = `${platform}-${arch}` + + if (platform === 'linux') { + key += isMusl() ? '-musl' : '-gnu' + } else if (platform === 'win32') { + key += '-msvc' + } + + const pkg = platforms[key] + if (!pkg) { + throw new Error( + `Unsupported platform: ${platform}-${arch}. ` + + `@cipherstash/profile supports: ${Object.keys(platforms).join(', ')}`, + ) + } + + // Prefer local .node binary (development / napi build) + try { + return require('./stack-profile-node.node') + } catch (_) {} + + // Fall back to platform-specific optional dependency + try { + return require(pkg) + } catch (_) {} + + throw new Error( + `Failed to load native binding for ${platform}-${arch}. ` + + `Ensure the optional dependency "${pkg}" is installed, ` + + `or run "napi build" for local development.`, + ) +} + +module.exports = loadBinding() diff --git a/languages/typescript/packages/profile/tsconfig.json b/languages/typescript/packages/profile/tsconfig.json new file mode 100644 index 000000000..eb9863389 --- /dev/null +++ b/languages/typescript/packages/profile/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["__tests__/**/*.ts", "index.d.ts"] +} diff --git a/languages/typescript/packages/profile/vitest.config.ts b/languages/typescript/packages/profile/vitest.config.ts new file mode 100644 index 000000000..b00cfdaf5 --- /dev/null +++ b/languages/typescript/packages/profile/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + testTimeout: 30_000, + }, +}) diff --git a/languages/typescript/packages/stack-auth-wasm/.gitignore b/languages/typescript/packages/stack-auth-wasm/.gitignore new file mode 100644 index 000000000..c271307eb --- /dev/null +++ b/languages/typescript/packages/stack-auth-wasm/.gitignore @@ -0,0 +1,4 @@ +target/ +pkg-deno/ +pkg-bundler/ +node_modules/ diff --git a/languages/typescript/packages/stack-auth-wasm/Cargo.toml b/languages/typescript/packages/stack-auth-wasm/Cargo.toml new file mode 100644 index 000000000..edeed0d6d --- /dev/null +++ b/languages/typescript/packages/stack-auth-wasm/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "stack-auth-wasm" +description = "WebAssembly bindings for stack-auth (Supabase Edge / Deno targets)" +version.workspace = true +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +license-file = "LICENSE" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +stack-auth = { workspace = true, features = ["http"] } +cts-common = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +serde-wasm-bindgen = "0.6" +js-sys = "0.3" +console_error_panic_hook = "0.1" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +# `console` feature gives us `web_sys::console::warn_2` for surfacing JS +# callback rejections inside `JsTokenStore` (CIP-3114). Target-gated to +# wasm32 because the consuming code in `JsTokenStore` is itself cfg-gated; +# on native the crate has no use for it and `cargo udeps --all-targets` +# (the `test-no-unused-cargo-dependencies` workflow) would flag it as +# unused. +web-sys = { version = "0.3", features = ["console"] } +# `Zeroizing` wraps the JSON-serialised `Token` while it sits in Rust heap +# between (de)serialisation and crossing the JS boundary, matching the +# behaviour of the upstream `CallbackTokenStore` on native. Target-gated for +# the same reason as `web-sys`. +zeroize = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" +base64 = { workspace = true } + +[package.metadata.wasm-pack.profile.release] +wasm-opt = ["-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"] + +[package.metadata.wasm-pack.profile.dev] +wasm-opt = false diff --git a/languages/typescript/packages/stack-auth-wasm/LICENSE b/languages/typescript/packages/stack-auth-wasm/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/languages/typescript/packages/stack-auth-wasm/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + +<https://polyformproject.org/licenses/internal-use/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/languages/typescript/packages/stack-auth-wasm/README.md b/languages/typescript/packages/stack-auth-wasm/README.md new file mode 100644 index 000000000..8ea800d99 --- /dev/null +++ b/languages/typescript/packages/stack-auth-wasm/README.md @@ -0,0 +1,37 @@ +# stack-auth-wasm + +WebAssembly bindings for [`stack-auth`](../). Consumed by the unified [`@cipherstash/auth`](../node/) npm package — this crate is the upstream source, not a published artifact. + +Scoped to `AccessKeyStrategy` (machine-to-machine auth). Region is derived from the CRN, and every issued token's `workspace` JWT claim is verified against the CRN — a mismatch surfaces as `WORKSPACE_MISMATCH`. + +The wrapped [`@cipherstash/auth/wasm-inline`](../node/wasm-inline.d.ts) entry returns a [`@byteslice/result`](https://www.npmjs.com/package/@byteslice/result) `Result`: `AccessKeyStrategy.create(...)` and `getToken()` resolve to `{ data }` on success or `{ failure }` (a discriminated `AuthFailure` tagged by `type`, carrying the live `error`, optional `help`/`url`, and per-variant payload such as `WORKSPACE_MISMATCH`'s `expected`/`actual`). The failure `type`s come from `AuthError` in the parent `stack-auth` crate. The lower-level raw `/wasm` bindings still *throw* a JS `Error` whose `.code` carries the same discriminant, with the structured failure attached as the `__authFailure` property. + +OAuth strategies, device-code flow, and profile-store loading are deliberately out of scope — they need Node-only APIs (filesystem device identity, browser launching) that can't be ported to wasm32. + +## Build + +The npm package's `build:wasm` script orchestrates everything: + +```sh +cd ../node && npm run build:wasm +``` + +This invokes `wasm-pack build --target bundler --out-dir ../node/wasm`, strips wasm-pack metadata, and runs `scripts/inline-wasm.mjs` to emit the inline-bytes variant. CI does the same in `.github/workflows/publish-auth-npm.yml`. + +## Test + +```sh +wasm-pack test --node +``` + +Pure-logic coverage — JWT claim extraction, services-as-plain-object serialisation, error-code mapping, constructor smoke checks. HTTP semantics are covered by the native `stack-auth/node/__tests__` vitest suite. + +## Published shape + +The `@cipherstash/auth` package exposes three wasm-related entries: + +- `@cipherstash/auth/wasm-inline` — hand-written ESM wrapper around the inline-bytes bundle (wasm embedded as base64). Exposes the slick options-object API: `AccessKeyStrategy.create(workspaceCrn, key, { store })`. Zero-config in Supabase Edge, Cloudflare Workers, Deno, Bun. +- `@cipherstash/auth/wasm` — raw sibling-`.wasm` shim from `wasm-pack --target bundler`. Lower-level surface (no options-object wrapper) for consumers using a wasm-aware bundler (Vite/Webpack). +- `@cipherstash/auth/cookies` — pure-JS helper `cookieStore({ request, responseHeaders, ... })` returning a `TokenStore`-shaped object. No wasm dependency; works in any WHATWG-fetch runtime, and forward-compatible with the future napi binding (CIP-3113). + +`AccessKeyStrategy.create()` accepts an optional `{ store }` field that takes any `{ load, save }` shape. JS callback rejections inside the store are logged via `web_sys::console::warn_2` rather than swallowed (CIP-3114). See [`../node/README.md`](../node/README.md) for consumer-facing usage and the `cookieStore` reference. diff --git a/languages/typescript/packages/stack-auth-wasm/package.json b/languages/typescript/packages/stack-auth-wasm/package.json new file mode 100644 index 000000000..60acdf579 --- /dev/null +++ b/languages/typescript/packages/stack-auth-wasm/package.json @@ -0,0 +1,18 @@ +{ + "name": "@cipherstash/stack-auth-wasm", + "version": "0.0.0-pre", + "description": "WebAssembly bindings for stack-auth (Supabase Edge / Deno targets)", + "license": "SEE LICENSE IN LICENSE", + "private": true, + "scripts": { + "build:wasm": "pnpm run build:bundler && pnpm run build:deno", + "build:bundler": "wasm-pack build --target bundler --out-dir pkg-bundler", + "build:deno": "wasm-pack build --target deno --out-dir pkg-deno", + "test:cargo": "wasm-pack test --node" + }, + "files": [ + "pkg-bundler/", + "pkg-deno/", + "README.md" + ] +} diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs new file mode 100644 index 000000000..72cb03d03 --- /dev/null +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -0,0 +1,893 @@ +//! WebAssembly bindings for `stack-auth`. +//! +//! Mirrors the wasm-compatible subset of the `stack-auth-node` napi crate: +//! `AccessKeyStrategy` (M2M auth) and `OidcFederationStrategy` (federating a third-party +//! OIDC JWT into a CTS service token via `/api/authorise`). The interactive +//! device-code flow and profile-store loading remain out of scope — they need +//! Node-only APIs (filesystem device identity, browser launching) that can't +//! be ported to wasm32. +//! +//! Targets Supabase Edge Functions and bundler consumers via +//! `wasm-pack build --target bundler` / `--target deno`. + +use std::collections::BTreeMap; + +use serde::Serialize; +use serde_wasm_bindgen::Serializer; +use stack_auth::{AuthError, AuthStrategy, ServiceToken}; +#[cfg(target_arch = "wasm32")] +use stack_auth::{OidcProvider, SecretToken, Token, TokenStore}; +use wasm_bindgen::prelude::*; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::JsFuture; +#[cfg(target_arch = "wasm32")] +use zeroize::Zeroizing; + +/// Route Rust panics to `console.error` with a readable message + stack. +/// Without this, panics surface as opaque `RuntimeError: unreachable` from +/// wasm bytecode offsets. +#[wasm_bindgen(start)] +fn module_init() { + console_error_panic_hook::set_once(); +} + +/// Build a JS `Error` carrying the structured failure for the `.mjs` shim to +/// turn into a `Result` `failure`. +/// +/// The serialized [`AuthError`] (`{ type, message, help?, url?, ...payload }`) +/// is attached as a branded `__authFailure` property — a plain JS object, via +/// `serialize_maps_as_objects` — and `.code` is kept on the error for parity +/// with the previous contract. +fn to_js_error(err: AuthError) -> JsValue { + let js_err: JsValue = js_sys::Error::new(&err.to_string()).into(); + + let serializer = Serializer::new().serialize_maps_as_objects(true); + // Always attach the `__authFailure` brand. If full serialization ever fails, + // fall back to a minimal `{ type, message }` object so the shim still sees a + // domain failure and returns `{ failure }` rather than re-throwing it as a + // panic — mirrors the napi seam's `unwrap_or_else` fallback in node/src/lib.rs. + let details = err.serialize(&serializer).unwrap_or_else(|_| { + let fallback = js_sys::Object::new(); + let _ = js_sys::Reflect::set( + &fallback, + &JsValue::from_str("type"), + &JsValue::from_str(err.error_code()), + ); + let _ = js_sys::Reflect::set( + &fallback, + &JsValue::from_str("message"), + &JsValue::from_str(&err.to_string()), + ); + fallback.into() + }); + let _ = js_sys::Reflect::set(&js_err, &JsValue::from_str("__authFailure"), &details); + let _ = js_sys::Reflect::set( + &js_err, + &JsValue::from_str("code"), + &JsValue::from_str(err.error_code()), + ); + + js_err +} + +#[derive(Serialize)] +struct TokenResultPayload { + // Bearer credential. Kept as `String` rather than `stack_auth::SecretToken` + // because the protections `SecretToken` provides (`ZeroizeOnDrop`, + // `OpaqueDebug`) don't survive `serde_wasm_bindgen` — once the value + // crosses the FFI boundary it lives in JS-managed memory with no zeroize + // equivalent. Wasm-side memory hygiene is tracked separately as the + // "never-expose-JWT" follow-up. + token: String, + subject: String, + #[serde(rename = "workspaceId")] + workspace_id: String, + issuer: String, + services: BTreeMap<String, String>, +} + +fn token_result_from(token: ServiceToken) -> Result<JsValue, JsValue> { + let subject = token.subject().map_err(to_js_error)?.to_string(); + let workspace_id = token.workspace_id().map_err(to_js_error)?.to_string(); + let issuer = token.issuer().map_err(to_js_error)?.to_string(); + let services = token + .services() + .map_err(to_js_error)? + .iter() + .map(|(k, v)| (k.as_str().to_string(), v.to_string())) + .collect(); + + let payload = TokenResultPayload { + token: token.as_str().to_string(), + subject, + workspace_id, + issuer, + services, + }; + // `json_compatible` serializes maps as plain objects rather than JS `Map`s, + // so consumers can `JSON.stringify` the result and read fields with normal + // object syntax — matches the `Record<string, string>` shape advertised in + // `wasm-types.d.ts`. + payload + .serialize(&Serializer::json_compatible()) + .map_err(JsValue::from) +} + +/// `TokenStore` adapter over a pair of JS callbacks. +/// +/// `load` is called with no arguments and is expected to return +/// `Promise<string | null | undefined>` — the previously-stored JSON +/// or a nullish value if nothing is cached. `save` is called with the +/// JSON string and is expected to return `Promise<void>`. +/// +/// Cfg-gated to `wasm32` because `js_sys::Function` is not `Send` and the +/// parent `stack_auth::TokenStore` trait drops the `Send + Sync` bound on +/// wasm32 to accommodate exactly this case. +#[cfg(target_arch = "wasm32")] +struct JsTokenStore { + load: js_sys::Function, + save: js_sys::Function, +} + +#[cfg(target_arch = "wasm32")] +impl TokenStore for JsTokenStore { + async fn load(&self) -> Option<Token> { + let promise = match self.load.call0(&JsValue::NULL) { + Ok(p) => p, + Err(err) => { + warn_callback("loadToken", "synchronous throw", &err); + return None; + } + }; + match JsFuture::from(js_sys::Promise::from(promise)).await { + Ok(result) => { + // Zero the JSON heap buffer on drop — it carries the bearer + // token in cleartext between the JS boundary and serde. + let json = Zeroizing::new(result.as_string()?); + serde_json::from_str(&json).ok() + } + Err(err) => { + warn_callback("loadToken", "promise rejection", &err); + None + } + } + } + + async fn save(&self, token: &Token) { + let Ok(json) = serde_json::to_string(token).map(Zeroizing::new) else { + return; + }; + let promise = match self.save.call1(&JsValue::NULL, &JsValue::from_str(&json)) { + Ok(p) => p, + Err(err) => { + warn_callback("saveToken", "synchronous throw", &err); + return; + } + }; + if let Err(err) = JsFuture::from(js_sys::Promise::from(promise)).await { + warn_callback("saveToken", "promise rejection", &err); + } + } +} + +/// Surface JS callback failures so consumers can see them — without this, +/// rejections in user-supplied `loadToken` / `saveToken` were invisible and +/// led to silent cache misses (e.g. when `setCookie` rejected a value +/// containing chars outside RFC 6265's allowed range). See CIP-3114. +#[cfg(target_arch = "wasm32")] +fn warn_callback(name: &str, kind: &str, err: &JsValue) { + let msg = format!("stack-auth: {name} {kind}"); + web_sys::console::warn_2(&JsValue::from_str(&msg), err); +} + +/// Best-effort human-readable detail for a JS error value, for embedding in an +/// [`AuthError`] message. Prefers a thrown string, then an `Error.message` +/// property, falling back to the `Debug` representation. +#[cfg(target_arch = "wasm32")] +fn js_error_detail(err: &JsValue) -> String { + err.as_string() + .or_else(|| { + js_sys::Reflect::get(err, &JsValue::from_str("message")) + .ok() + .and_then(|m| m.as_string()) + }) + .unwrap_or_else(|| format!("{err:?}")) +} + +/// `OidcProvider` adapter over a JS callback. +/// +/// `getJwt` is called with no arguments and is expected to return +/// `Promise<string>` — the current third-party OIDC JWT to federate. Unlike +/// [`JsTokenStore`], a failure here is fatal: federation can't proceed without +/// a JWT, so it surfaces as an [`AuthError`] rather than a silent cache miss. +/// The failure is still logged via [`warn_callback`] so the JS-side cause is +/// visible. +#[cfg(target_arch = "wasm32")] +struct JsOidcProvider { + get_jwt: js_sys::Function, +} + +#[cfg(target_arch = "wasm32")] +impl OidcProvider for JsOidcProvider { + async fn fetch(&self) -> Result<SecretToken, AuthError> { + let promise = self.get_jwt.call0(&JsValue::NULL).map_err(|err| { + warn_callback("getJwt", "synchronous throw", &err); + AuthError::Server(stack_auth::ServerError(format!( + "getJwt callback threw: {}", + js_error_detail(&err) + ))) + })?; + let result = JsFuture::from(js_sys::Promise::from(promise)) + .await + .map_err(|err| { + warn_callback("getJwt", "promise rejection", &err); + AuthError::Server(stack_auth::ServerError(format!( + "getJwt callback rejected: {}", + js_error_detail(&err) + ))) + })?; + // `SecretToken` owns the JWT string and zeroes its heap buffer on drop + // (it's `ZeroizeOnDrop`) — it carries the bearer credential between the + // JS boundary and the federation HTTP request. + let jwt = result.as_string().ok_or_else(|| { + AuthError::Server(stack_auth::ServerError( + "getJwt callback did not return a string".to_string(), + )) + })?; + Ok(SecretToken::new(jwt)) + } +} + +/// Parse a workspace CRN string, mapping a parse failure to the `INVALID_CRN` +/// error code. Shared by every factory that takes a workspace CRN +/// (`AccessKeyStrategy`, `OidcFederationStrategy`). +fn parse_workspace_crn(workspace_crn: &str) -> Result<cts_common::Crn, JsValue> { + workspace_crn + .parse() + .map_err(|e| to_js_error(AuthError::from(e))) +} + +enum AccessKeyStrategyInner { + NoStore(stack_auth::AccessKeyStrategy), + #[cfg(target_arch = "wasm32")] + WithStore(stack_auth::AccessKeyStrategy<JsTokenStore>), +} + +impl AccessKeyStrategyInner { + async fn get_token(&self) -> Result<ServiceToken, AuthError> { + match self { + Self::NoStore(s) => s.get_token().await, + #[cfg(target_arch = "wasm32")] + Self::WithStore(s) => s.get_token().await, + } + } +} + +#[wasm_bindgen] +pub struct AccessKeyStrategy { + inner: AccessKeyStrategyInner, +} + +#[wasm_bindgen] +impl AccessKeyStrategy { + /// Create a new `AccessKeyStrategy` for the given workspace CRN and + /// access key. Region is derived from the CRN — there's no separate + /// region argument — so the strategy can't be configured for one + /// workspace's region while the CRN says another. + /// + /// Every issued token's workspace claim is verified against the CRN; + /// a mismatch fails the call with a `WORKSPACE_MISMATCH` error. + pub fn create(workspace_crn: String, access_key: String) -> Result<AccessKeyStrategy, JsValue> { + let crn = parse_workspace_crn(&workspace_crn)?; + let key: stack_auth::AccessKey = access_key + .parse() + .map_err(|e| to_js_error(AuthError::from(e)))?; + let inner = stack_auth::AccessKeyStrategy::new(crn, key).map_err(to_js_error)?; + Ok(AccessKeyStrategy { + inner: AccessKeyStrategyInner::NoStore(inner), + }) + } + + /// Create an `AccessKeyStrategy` backed by external token-store callbacks. + /// + /// `loadToken` is called on cold start before any HTTP request fires; it + /// must return the previously-saved JSON string (or null/undefined for + /// "cache miss") wrapped in a Promise. `saveToken` receives the JSON + /// string after every successful refresh and must persist it; its return + /// Promise resolves to undefined. + /// + /// Use this to back the strategy with HTTP-only cookies (Supabase Edge), + /// KV stores (Cloudflare Workers), or any other request-scoped cache. + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen(js_name = createWithStore)] + pub fn create_with_store( + workspace_crn: String, + access_key: String, + load_token: js_sys::Function, + save_token: js_sys::Function, + ) -> Result<AccessKeyStrategy, JsValue> { + let crn = parse_workspace_crn(&workspace_crn)?; + let key: stack_auth::AccessKey = access_key + .parse() + .map_err(|e| to_js_error(AuthError::from(e)))?; + let store = JsTokenStore { + load: load_token, + save: save_token, + }; + let inner = stack_auth::AccessKeyStrategy::builder(crn, key) + .with_token_store(store) + .build() + .map_err(to_js_error)?; + Ok(AccessKeyStrategy { + inner: AccessKeyStrategyInner::WithStore(inner), + }) + } + + /// Retrieve a valid access token, refreshing or re-authenticating as needed. + #[wasm_bindgen(js_name = getToken)] + pub async fn get_token(&self) -> Result<JsValue, JsValue> { + let token = self.inner.get_token().await.map_err(to_js_error)?; + token_result_from(token) + } +} + +/// Cfg-gated to wasm32: `OidcFederationStrategy` is generic over the JWT provider, and +/// the only provider the bindings offer (`JsOidcProvider`) wraps a +/// `js_sys::Function`, which exists only on wasm32. The native build of this +/// crate (used for `cargo clippy` / host `cargo test`) therefore has no +/// `OidcFederationStrategy` — there is nothing native-testable about a JS-callback type. +#[cfg(target_arch = "wasm32")] +enum OidcFederationStrategyInner { + NoStore(stack_auth::OidcFederationStrategy<JsOidcProvider>), + WithStore(stack_auth::OidcFederationStrategy<JsOidcProvider, JsTokenStore>), +} + +#[cfg(target_arch = "wasm32")] +impl OidcFederationStrategyInner { + async fn get_token(&self) -> Result<ServiceToken, AuthError> { + match self { + Self::NoStore(s) => s.get_token().await, + Self::WithStore(s) => s.get_token().await, + } + } +} + +/// Federates a third-party OIDC JWT (Clerk, Supabase, …) into a CTS service +/// token. See the crate-level docs and `stack_auth::OidcFederationStrategy`. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub struct OidcFederationStrategy { + inner: OidcFederationStrategyInner, +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +impl OidcFederationStrategy { + /// Create an `OidcFederationStrategy` for the given workspace CRN. + /// + /// The CRN format is `crn:<region>:<workspace-id>` (e.g. + /// `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from + /// the CRN and used for service discovery; the workspace ID is used to + /// verify every federated token belongs to the right workspace. + /// + /// `getJwt` is called on every federation — initial auth and every + /// re-federation after expiry — and must return `Promise<string>` + /// resolving to the *current* third-party OIDC JWT (e.g. by calling + /// `clerk.session.getToken()`). + /// + /// `baseUrl`, when supplied, pins this strategy to a specific CTS host — + /// e.g. a self-hosted CTS or a local mock auth server. It overrides region + /// service discovery and is scoped to this strategy alone. In wasm there is + /// no `CS_CTS_HOST` env fallback (the sandbox can't read env), so `baseUrl` + /// is the only way to target a host other than the region-discovered one. + pub fn create( + workspace_crn: String, + get_jwt: js_sys::Function, + base_url: Option<String>, + ) -> Result<OidcFederationStrategy, JsValue> { + let crn = parse_workspace_crn(&workspace_crn)?; + let inner = stack_auth::OidcFederationStrategy::builder(crn, JsOidcProvider { get_jwt }) + .maybe_base_url(base_url) + .map_err(to_js_error)? + .build() + .map_err(to_js_error)?; + Ok(OidcFederationStrategy { + inner: OidcFederationStrategyInner::NoStore(inner), + }) + } + + /// Create an `OidcFederationStrategy` backed by external token-store callbacks. + /// + /// Behaves like `create` but persists the federated CTS + /// token through `loadToken` / `saveToken` — see + /// [`AccessKeyStrategy::create_with_store`] for the callback contract. Use + /// this to back the strategy with an HTTP-only cookie so a federated token + /// survives across Edge Function invocations without re-federating. + /// + /// `baseUrl` behaves as in `create` — an explicit, + /// strategy-scoped CTS host that overrides region service discovery. + #[wasm_bindgen(js_name = createWithStore)] + pub fn create_with_store( + workspace_crn: String, + get_jwt: js_sys::Function, + load_token: js_sys::Function, + save_token: js_sys::Function, + base_url: Option<String>, + ) -> Result<OidcFederationStrategy, JsValue> { + let crn = parse_workspace_crn(&workspace_crn)?; + let store = JsTokenStore { + load: load_token, + save: save_token, + }; + let inner = stack_auth::OidcFederationStrategy::builder(crn, JsOidcProvider { get_jwt }) + .maybe_base_url(base_url) + .map_err(to_js_error)? + .with_token_store(store) + .build() + .map_err(to_js_error)?; + Ok(OidcFederationStrategy { + inner: OidcFederationStrategyInner::WithStore(inner), + }) + } + + /// Retrieve a valid CTS service token, federating or re-federating as needed. + #[wasm_bindgen(js_name = getToken)] + pub async fn get_token(&self) -> Result<JsValue, JsValue> { + self.inner + .get_token() + .await + .map_err(to_js_error) + .and_then(token_result_from) + } +} + +#[cfg(all(test, target_arch = "wasm32"))] +mod tests { + use super::*; + use base64::Engine; + use wasm_bindgen_test::wasm_bindgen_test; + + /// Build an unsigned JWT-shaped token: `<header>.<payload>.<sig>`. + /// Signature segment is a dummy `"sig"` literal — the JWT-claim decoder + /// in stack-auth only reads the payload segment and ignores the signature. + fn make_jwt(claims: serde_json::Value) -> String { + let header = serde_json::json!({"alg": "HS256", "typ": "JWT"}); + let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&claims).unwrap()); + format!("{header_b64}.{payload_b64}.sig") + } + + fn make_service_token(iss: &str, zerokms_url: &str) -> ServiceToken { + let claims = serde_json::json!({ + "iss": iss, + "sub": "CS|test-user", + "aud": "test-aud", + "iat": 1_700_000_000u64, + "exp": 4_000_000_000u64, + "workspace": "ZVATKW3VHMFG27DY", + "scope": "", + "services": { "zerokms": zerokms_url }, + }); + ServiceToken::new(SecretToken::new(make_jwt(claims))) + } + + fn error_code_of(err: &JsValue) -> String { + js_sys::Reflect::get(err, &JsValue::from_str("code")) + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default() + } + + /// The bindings structs deliberately don't derive `Debug` (wrappers + /// shouldn't leak internal state via Debug — matches the node crate's + /// posture), so `expect_err` / `unwrap_err` aren't available. + fn expect_js_err<T>(result: Result<T, JsValue>) -> JsValue { + match result { + Ok(_) => panic!("expected Err, got Ok"), + Err(e) => e, + } + } + + #[wasm_bindgen_test] + fn to_js_error_attaches_code_property() { + let err = to_js_error(AuthError::AccessDenied(stack_auth::AccessDenied)); + assert_eq!(error_code_of(&err), "ACCESS_DENIED"); + let err = to_js_error(AuthError::Server(stack_auth::ServerError("boom".into()))); + assert_eq!(error_code_of(&err), "SERVER_ERROR"); + } + + /// Regression for the FFI mapping of the workspace-verification error. + /// A full HTTP-roundtrip test isn't viable on wasm32 (no mocktail-style + /// fetch interception in the wasm-bindgen test runner), so we exercise + /// just the boundary: any `WorkspaceMismatch` reaching `to_js_error` + /// must surface as `WORKSPACE_MISMATCH`. The underlying check is + /// covered by `stack_auth::access_key_strategy` tests on the native + /// target. + #[wasm_bindgen_test] + fn workspace_mismatch_maps_to_workspace_mismatch_code() { + let err = to_js_error(AuthError::WorkspaceMismatch( + stack_auth::WorkspaceMismatch { + expected_workspace: "ZVATKW3VHMFG27DY".parse().unwrap(), + token_workspace: "AAAAAAAAAAAAAAAA".parse().unwrap(), + }, + )); + assert_eq!(error_code_of(&err), "WORKSPACE_MISMATCH"); + } + + fn auth_failure_of(err: &JsValue) -> JsValue { + js_sys::Reflect::get(err, &JsValue::from_str("__authFailure")) + .expect("error should carry the __authFailure brand") + } + + fn field(obj: &JsValue, key: &str) -> Option<String> { + js_sys::Reflect::get(obj, &JsValue::from_str(key)) + .ok() + .and_then(|v| v.as_string()) + } + + /// The `__authFailure` object is the only thing `wasm-inline.mjs`'s + /// `toFailure` reads to build a `Result` failure — `.code` above is just + /// legacy parity. Pin the full serialized envelope (type + structured + /// payload + help + message) so dropping the attachment, or the serializer + /// losing a field, fails here rather than silently breaking the whole + /// wasm Result path. + #[wasm_bindgen_test] + fn to_js_error_attaches_auth_failure_object_with_payload_and_help() { + let err = to_js_error(AuthError::WorkspaceMismatch( + stack_auth::WorkspaceMismatch { + expected_workspace: "ZVATKW3VHMFG27DY".parse().unwrap(), + token_workspace: "AAAAAAAAAAAAAAAA".parse().unwrap(), + }, + )); + let details = auth_failure_of(&err); + assert_eq!( + field(&details, "type").as_deref(), + Some("WORKSPACE_MISMATCH") + ); + assert_eq!( + field(&details, "expected").as_deref(), + Some("ZVATKW3VHMFG27DY") + ); + assert_eq!( + field(&details, "actual").as_deref(), + Some("AAAAAAAAAAAAAAAA") + ); + assert!( + field(&details, "help").is_some(), + "help must ride along in __authFailure" + ); + assert!(field(&details, "message").is_some()); + } + + #[wasm_bindgen_test] + fn token_result_from_extracts_jwt_claims() { + let token = make_service_token("https://cts.example.com/", "https://zerokms.example.com/"); + let value = token_result_from(token).expect("conversion should succeed"); + + let subject = + js_sys::Reflect::get(&value, &JsValue::from_str("subject")).expect("has subject"); + assert_eq!(subject.as_string().as_deref(), Some("CS|test-user")); + + let workspace = js_sys::Reflect::get(&value, &JsValue::from_str("workspaceId")) + .expect("has workspaceId"); + assert_eq!(workspace.as_string().as_deref(), Some("ZVATKW3VHMFG27DY")); + + let issuer = + js_sys::Reflect::get(&value, &JsValue::from_str("issuer")).expect("has issuer"); + assert_eq!( + issuer.as_string().as_deref(), + Some("https://cts.example.com/") + ); + + // `services` must serialise as a plain object so `JSON.stringify` + // returns the entries — not as a JS `Map`, which stringifies to `{}`. + let services = + js_sys::Reflect::get(&value, &JsValue::from_str("services")).expect("has services"); + assert!( + !services.is_instance_of::<js_sys::Map>(), + "services must not be a JS Map (JSON.stringify would drop entries)", + ); + let zerokms_url = js_sys::Reflect::get(&services, &JsValue::from_str("zerokms")) + .expect("services has zerokms entry"); + assert_eq!( + zerokms_url.as_string().as_deref(), + Some("https://zerokms.example.com/") + ); + } + + #[wasm_bindgen_test] + fn token_result_from_rejects_non_jwt() { + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + let err = token_result_from(token).expect_err("non-JWT should fail"); + assert_eq!(error_code_of(&err), "INVALID_TOKEN"); + } + + const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; + const VALID_KEY: &str = "CSAKtestKeyId.testKeySecret"; + + #[wasm_bindgen_test] + fn access_key_strategy_rejects_invalid_crn() { + let err = expect_js_err(AccessKeyStrategy::create( + "not-a-crn".to_string(), + VALID_KEY.to_string(), + )); + assert_eq!(error_code_of(&err), "INVALID_CRN"); + } + + #[wasm_bindgen_test] + fn access_key_strategy_rejects_invalid_key() { + let err = expect_js_err(AccessKeyStrategy::create( + VALID_CRN.to_string(), + "not-a-valid-key".to_string(), + )); + assert_eq!(error_code_of(&err), "INVALID_ACCESS_KEY"); + } + + #[wasm_bindgen_test] + fn access_key_strategy_accepts_valid_inputs() { + let result = AccessKeyStrategy::create(VALID_CRN.to_string(), VALID_KEY.to_string()); + assert!(result.is_ok()); + } + + fn empty_load_fn() -> js_sys::Function { + // `async () => null` + js_sys::Function::new_no_args("return Promise.resolve(null);") + } + + fn noop_save_fn() -> js_sys::Function { + // `async (_) => undefined` + js_sys::Function::new_with_args("_json", "return Promise.resolve();") + } + + #[wasm_bindgen_test] + fn create_with_store_rejects_invalid_crn() { + let err = expect_js_err(AccessKeyStrategy::create_with_store( + "not-a-crn".to_string(), + VALID_KEY.to_string(), + empty_load_fn(), + noop_save_fn(), + )); + assert_eq!( + error_code_of(&err), + "INVALID_CRN", + "invalid CRN should surface INVALID_CRN even on the store variant" + ); + } + + #[wasm_bindgen_test] + fn create_with_store_rejects_invalid_access_key() { + let err = expect_js_err(AccessKeyStrategy::create_with_store( + VALID_CRN.to_string(), + "not-a-valid-key".to_string(), + empty_load_fn(), + noop_save_fn(), + )); + assert_eq!( + error_code_of(&err), + "INVALID_ACCESS_KEY", + "invalid access key should surface INVALID_ACCESS_KEY" + ); + } + + #[wasm_bindgen_test] + fn create_with_store_accepts_valid_inputs() { + let result = AccessKeyStrategy::create_with_store( + VALID_CRN.to_string(), + VALID_KEY.to_string(), + empty_load_fn(), + noop_save_fn(), + ); + assert!( + result.is_ok(), + "valid CRN + key + callbacks should construct successfully" + ); + } + + #[wasm_bindgen_test] + async fn js_token_store_load_returns_none_on_callback_throw() { + use stack_auth::TokenStore as _; + // A throwing `loadToken` mustn't crash — it should be treated as a + // cache miss so the strategy falls through to initial auth. + // CIP-3114: prior to the fix this still returned None (via `.ok()?`) + // but without surfacing the throw. With the fix, the throw is logged + // via `web_sys::console::warn_2`; behaviour-wise we just confirm + // the call returns None rather than panicking. + let store = JsTokenStore { + load: js_sys::Function::new_no_args("throw new Error('boom');"), + save: noop_save_fn(), + }; + assert!( + store.load().await.is_none(), + "throwing loadToken should produce a cache miss, not a crash" + ); + } + + #[wasm_bindgen_test] + async fn js_token_store_save_swallows_callback_throw() { + use stack_auth::TokenStore as _; + // A throwing `saveToken` mustn't crash the surrounding refresh path. + // Trait contract is "best-effort" — save returns `()` regardless. + let store = JsTokenStore { + load: empty_load_fn(), + save: js_sys::Function::new_with_args("_json", "throw new Error('boom');"), + }; + // `Token`'s fields are `pub(crate)`; round-trip through serde to build + // one from this crate without touching the field privacy. + let token: Token = serde_json::from_str( + r#"{"access_token":"dummy","token_type":"Bearer","expires_at":4000000000}"#, + ) + .unwrap(); + // No assertion needed beyond "this doesn't panic". + store.save(&token).await; + } + + fn jwt_fn(jwt: &str) -> js_sys::Function { + // `async () => "<jwt>"` + js_sys::Function::new_no_args(&format!("return Promise.resolve('{jwt}');")) + } + + #[wasm_bindgen_test] + fn oidc_federation_strategy_rejects_invalid_crn() { + let err = expect_js_err(OidcFederationStrategy::create( + "not-a-crn".to_string(), + jwt_fn("h.p.s"), + None, + )); + assert_eq!(error_code_of(&err), "INVALID_CRN"); + } + + /// A structurally well-formed CRN whose workspace segment fails + /// `WorkspaceId` validation is rejected with `INVALID_CRN` — the path the + /// old `INVALID_WORKSPACE_ID` test covered before the factory took a CRN. + /// "not-a-crn" above fails at the `crn:` prefix; this exercises the + /// workspace sub-parser instead. + #[wasm_bindgen_test] + fn oidc_federation_strategy_rejects_crn_with_malformed_workspace() { + let err = expect_js_err(OidcFederationStrategy::create( + "crn:ap-southeast-2.aws:not-a-valid-workspace".to_string(), + jwt_fn("h.p.s"), + None, + )); + assert_eq!(error_code_of(&err), "INVALID_CRN"); + } + + #[wasm_bindgen_test] + fn oidc_federation_strategy_accepts_valid_inputs() { + let result = OidcFederationStrategy::create(VALID_CRN.to_string(), jwt_fn("h.p.s"), None); + assert!(result.is_ok()); + } + + /// A supplied `baseUrl` override is accepted and threaded into the builder. + #[wasm_bindgen_test] + fn oidc_federation_strategy_accepts_valid_base_url() { + let result = OidcFederationStrategy::create( + VALID_CRN.to_string(), + jwt_fn("h.p.s"), + Some("https://cts.example.com".to_string()), + ); + assert!(result.is_ok()); + } + + /// An empty `baseUrl` string is treated as absent (falls back to region + /// discovery), not as an invalid URL. + #[wasm_bindgen_test] + fn oidc_federation_strategy_treats_empty_base_url_as_absent() { + let result = OidcFederationStrategy::create( + VALID_CRN.to_string(), + jwt_fn("h.p.s"), + Some(String::new()), + ); + assert!(result.is_ok()); + } + + /// A malformed `baseUrl` surfaces as `INVALID_URL`. + #[wasm_bindgen_test] + fn oidc_federation_strategy_rejects_invalid_base_url() { + let err = expect_js_err(OidcFederationStrategy::create( + VALID_CRN.to_string(), + jwt_fn("h.p.s"), + Some("not a url".to_string()), + )); + assert_eq!(error_code_of(&err), "INVALID_URL"); + } + + #[wasm_bindgen_test] + fn oidc_create_with_store_rejects_invalid_crn() { + let err = expect_js_err(OidcFederationStrategy::create_with_store( + "not-a-crn".to_string(), + jwt_fn("h.p.s"), + empty_load_fn(), + noop_save_fn(), + None, + )); + assert_eq!(error_code_of(&err), "INVALID_CRN"); + } + + #[wasm_bindgen_test] + fn oidc_create_with_store_accepts_valid_inputs() { + let result = OidcFederationStrategy::create_with_store( + VALID_CRN.to_string(), + jwt_fn("h.p.s"), + empty_load_fn(), + noop_save_fn(), + None, + ); + assert!(result.is_ok()); + } + + /// The store variant accepts a valid `baseUrl` override — mirrors + /// `oidc_federation_strategy_accepts_valid_base_url` on the plain `create`. + #[wasm_bindgen_test] + fn oidc_create_with_store_accepts_valid_base_url() { + let result = OidcFederationStrategy::create_with_store( + VALID_CRN.to_string(), + jwt_fn("h.p.s"), + empty_load_fn(), + noop_save_fn(), + Some("https://cts.example.com".to_string()), + ); + assert!(result.is_ok()); + } + + /// The store variant treats an empty `baseUrl` as absent — mirrors + /// `oidc_federation_strategy_treats_empty_base_url_as_absent` on `create`. + #[wasm_bindgen_test] + fn oidc_create_with_store_treats_empty_base_url_as_absent() { + let result = OidcFederationStrategy::create_with_store( + VALID_CRN.to_string(), + jwt_fn("h.p.s"), + empty_load_fn(), + noop_save_fn(), + Some(String::new()), + ); + assert!(result.is_ok()); + } + + /// The store variant also accepts and validates a `baseUrl` override. + #[wasm_bindgen_test] + fn oidc_create_with_store_rejects_invalid_base_url() { + let err = expect_js_err(OidcFederationStrategy::create_with_store( + VALID_CRN.to_string(), + jwt_fn("h.p.s"), + empty_load_fn(), + noop_save_fn(), + Some("not a url".to_string()), + )); + assert_eq!(error_code_of(&err), "INVALID_URL"); + } + + #[wasm_bindgen_test] + async fn js_oidc_provider_returns_jwt() { + let provider = JsOidcProvider { + get_jwt: jwt_fn("header.payload.signature"), + }; + let jwt = provider.fetch().await.expect("getJwt should succeed"); + assert_eq!(jwt.as_str(), "header.payload.signature"); + } + + #[wasm_bindgen_test] + async fn js_oidc_provider_errors_on_callback_throw() { + let provider = JsOidcProvider { + get_jwt: js_sys::Function::new_no_args("throw new Error('boom');"), + }; + let err = match provider.fetch().await { + Ok(_) => panic!("expected getJwt throw to surface as an error"), + Err(e) => e, + }; + assert!(matches!(err, AuthError::Server(_)), "got: {err:?}"); + } + + #[wasm_bindgen_test] + async fn js_oidc_provider_errors_on_non_string_result() { + let provider = JsOidcProvider { + get_jwt: js_sys::Function::new_no_args("return Promise.resolve(42);"), + }; + let err = match provider.fetch().await { + Ok(_) => panic!("expected non-string getJwt result to surface as an error"), + Err(e) => e, + }; + assert!(matches!(err, AuthError::Server(_)), "got: {err:?}"); + } +} diff --git a/mise.test.toml b/mise.test.toml new file mode 100644 index 000000000..57e4acb9a --- /dev/null +++ b/mise.test.toml @@ -0,0 +1,42 @@ +# The `test` environment: `mise x --env test -- …` in the root and stack-* +# tasks, and MISE_ENV=test in the CI jobs that run them. Carried from +# cipherstash-suite, keeping only what the stack crates read; the suite's CTS, +# ZeroKMS and database settings serve services this repository does not run. +# +# The cargo tools live here rather than in mise.toml so that packages/eql and +# protect-ffi, which inherit the root mise.toml, do not install them (see the +# note in mise.toml). Each is pinned: mise compiles a `cargo:` tool with the +# runner's default rustc, not the root's 1.94.1, so a `latest` that raises its +# minimum Rust fails the install. cargo-udeps 0.1.61 did exactly that +# (cargo@0.96 needs rustc 1.93; the Ubuntu runners ship 1.92). Bump a pin +# deliberately, after checking it still builds with the runners' rustc. +[tools] +"cargo:cargo-nextest" = "0.9.146" +# Coverage → CRAP (Change Risk Anti-Patterns) metric tooling. `cargo llvm-cov` +# produces an LCOV file that `cargo crap` scores; see `mise run crap:stack-auth`. +# cargo-llvm-cov needs the root rust's `llvm-tools-preview` component. +"cargo:cargo-llvm-cov" = "0.9.1" +"cargo:cargo-crap" = "0.6.1" +# Mutation testing for the stack crates; config in .cargo/mutants.toml, run +# via `mutants:<crate>`. +"cargo:cargo-mutants" = "27.1.0" +# Fuzzing (libFuzzer) for the pure-`&str` parsers. The `cargo-fuzz` binary +# installs on stable, but running a target needs the nightly toolchain +# (`cargo +nightly fuzz run …`); see the `fuzz:*` tasks. +"cargo:cargo-fuzz" = "0.13.2" +# Runs on nightly-2026-07-10: `cargo +nightly-2026-07-10 udeps`. 0.1.60, not +# 0.1.61: see above. +"cargo:cargo-udeps" = "0.1.60" + +[env] +# Annoyingly if you pass --env test to mise it doesn't set this automatically +MISE_ENV = "test" + +# Local service hosts, as in the suite. The stack-auth tests set their own +# values with temp-env where they depend on one. +CS_CTS_HOST = "http://localhost:3001" +CS_IDP_HOST = "http://localhost:3030" +CS_IDP_CLIENT_ID = "admin_test_client_id" +CS_VITUR_HOST = "http://localhost:3002" +CS_TEST_ZEROKMS_HOST = "http://localhost:3002" +CS_REGION = "ap-southeast-2.aws" diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..97558c9cb --- /dev/null +++ b/mise.toml @@ -0,0 +1,294 @@ +# The repository root is the root of the Rust and Go toolchains: the Cargo +# workspace in Cargo.toml (the stack-* crates and the three node bindings) and +# the Go module in languages/golang. +# +# EQL (packages/eql) and protect-ffi (languages/typescript/packages/protect-ffi) +# keep their own mise.toml. A tool they pin there overrides the pin here, in +# their folder only. + +[task_config] +includes = [ + "packages/stack-auth/tasks.toml", + "packages/stack-kms/tasks.toml", + "packages/stack-profile/tasks.toml", + "packages/stack-encrypt/tasks.toml", + "packages/stack-guest-abi/tasks.toml", +] + +[tools] +# 1.94.1, not latest: the stack-encrypt `tests/ui` trybuild snapshots record +# this compiler's diagnostics. `llvm-tools-preview` is required by +# cargo-llvm-cov to produce coverage data. +rust = { version = "1.94.1", components = "rustc,cargo,rustfmt,rust-std,clippy,rust-docs,llvm-tools-preview", targets = "wasm32-unknown-unknown,wasm32-wasip1" } +# No `cargo:` tools here. mise merges config down the tree, so every tool in +# this file is also installed for packages/eql and protect-ffi, whose CI runs +# `mise install` and `mise run` in their own folders. mise compiles a `cargo:` +# tool with the runner's default rustc rather than the pinned one, so one +# `latest` that outgrows that compiler breaks jobs that never use the tool. +# The cargo tools (nextest, llvm-cov, crap, mutants, fuzz, udeps) live in +# mise.test.toml, pinned, and load only with `--env test` / MISE_ENV=test. +# The Go module (languages/golang) — the wazero host of the WASI guests. +# CGO_ENABLED=0 throughout; see `go:test`. +go = "1.26" +golangci-lint = "2.14.0" +# wasm-pack for the auth binding's `build:wasm`, which builds +# languages/typescript/packages/stack-auth-wasm into the auth package's wasm/. +# Same pin and backend id as protect-ffi's mise.toml (`wasm-pack` is not a +# short name in mise's registry). +"aqua:wasm-bindgen/wasm-pack" = "0.13.1" + +[tasks."test:doc"] +description = "Run documentation tests" +run = "mise x --env test -- cargo test --doc --all-features --workspace" + +# Rustdoc with warnings as errors, one crate at a time. `test:doc` runs the +# doc *examples*; it cannot see a broken intra-doc link or a rustdoc warning, +# which only a `cargo doc` build surfaces. Each crate defines its own +# `doc:<crate>` in its tasks.toml so a change to one crate is checked with +# `mise run doc:<crate>` alone; this task is the one entry point CI runs, and +# it only fans out over those. A crate that adds a `doc:` task joins it. +[tasks.doc] +description = "Build docs for every crate that defines a doc:<crate> task (warnings are errors)" +depends = ["doc:*"] + +# Mutation testing, the same shape as `doc`: each crate that opts in defines +# `mutants:<crate>` in its tasks.toml (a full sweep of that crate, reading +# .cargo/mutants.toml), and this fans out over them. Slow — see the per-crate +# tasks for timings. +[tasks.mutants] +description = "Full mutation-testing sweep of every crate that defines a mutants:<crate> task (slow)" +depends = ["mutants:*"] + +# WASI gate for the Go/wazero target (docs/plans/stack-encrypt-go-bindings.md, +# docs/wasm-analysis.md Layer 6). Each listed crate must compile for +# `wasm32-wasip1` AND keep two dependency families out of its graph: +# +# - JS-host backends (`wasm-bindgen`/`web-sys`/`js-sys`): imports of JS host +# functions that a non-JS runtime like wazero cannot satisfy — the module +# type-checks but fails to instantiate. +# - The native HTTP/TLS stack (`reqwest`/`hyper`/`aws-lc-sys`): on wasip1 +# reqwest 0.13.4+ selects a native backend that needs tokio-full and a C +# TLS provider, neither of which builds for WASI. HTTP is provided by the +# host (see the plan), so it must be out of the WASI build by construction. +# +# The suite's version of this task also checked the suite crates the stack +# crates depend on (zerokms-protocol, cipherstash-core, recipher, cts-common, +# cllw-ore). Here they come from crates.io and are checked through the stack +# crates' graphs. +[tasks."wasm:wasi-check"] +description = "Check the stack crates compile for wasm32-wasip1 with no JS-host or native-HTTP deps (Go/wazero target)" +# bash, not the default sh: the script uses `set -o pipefail` (Ubuntu's sh is +# dash, which rejects it — macOS sh is bash-in-sh-mode, so it passes locally). +shell = "bash -c" +run = """ +set -euo pipefail +# The WASI std target is in the mise `[tools]` rust targets; add it here as +# well so the task is self-contained (idempotent, no-op if present). +rustup target add wasm32-wasip1 +check() { + local crate="$1"; shift + echo "==> $crate (wasm32-wasip1)" + cargo check --target wasm32-wasip1 -p "$crate" "$@" + # -e normal: the gate's property is about what links into the module, and + # dev-deps never do — a wasm-bindgen-test dev-dep must not fail this. + # Capture the tree before grepping: `... | grep -q` exits on first match, + # cargo tree can die on EPIPE, and pipefail would adopt that status — + # silently masking a MATCH once the tree outgrows the pipe buffer. + local tree + # --color never: CI sets CARGO_TERM_COLOR=always, and the colour codes + # around the tree prefix stop the grep below from ever matching. + tree=$(cargo tree --color never --target wasm32-wasip1 -e normal -p "$crate" "$@") + if grep -Eq '^[^a-z]*(wasm-bindgen|web-sys|js-sys) ' <<<"$tree"; then + echo "error: $crate pulls a JS-host backend (wasm-bindgen/web-sys/js-sys) on WASI — not wazero-loadable" >&2 + exit 1 + fi + if grep -Eq '^[^a-z]*(reqwest|hyper|aws-lc-sys) ' <<<"$tree"; then + echo "error: $crate pulls the native HTTP/TLS stack (reqwest/hyper/aws-lc-sys) on WASI — HTTP must come from the host" >&2 + exit 1 + fi +} +# The stack crates: their default `http` feature is the reqwest transport, +# which a host with its own transport (the wazero guest) builds without. +check stack-auth --no-default-features +check stack-kms --no-default-features +check stack-encrypt --no-default-features +# The Go binding's credential guest (ADR-0005) compiles stack-profile for +# WASI: the hostname and the process id are native-only there. +check stack-profile +# The ABI every guest under languages/golang shares (allocator, registry, +# status table, transport import); its export and import modules exist only +# here. +check stack-guest-abi +echo "all WASI crates compile with no JS-host or native-HTTP deps" +""" + +# Companion to wasm:wasi-check, on the host target: the stack crates' +# no-default-features shape must also pass its unit tests, doctests, and +# rustdoc — `cargo check` alone misses doc examples and intra-doc links that +# reference http-only items. +[tasks."wasm:no-http-test"] +description = "Test and doc-build (warnings are errors) the stack crates with default features off — the shape the WASI guest builds against" +shell = "bash -c" +run = """ +set -euo pipefail +# One crate per invocation: naming several -p at once lets dev-dep feature +# unification turn `http` back on, silently testing the wrong shape. The +# same applies within a crate: every stack-* path dev-dependency must set +# `default-features = false` or its defaults re-enter this graph. +for crate in stack-auth stack-kms stack-encrypt; do + echo "==> $crate (tests + doctests, --no-default-features)" + cargo test -p "$crate" --no-default-features + echo "==> $crate (rustdoc, --no-default-features)" + RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p "$crate" --no-default-features +done +""" + +# The stack-encrypt WASI guest (languages/golang/stackencrypt/guest) is a +# detached workspace — like the fuzz crates — so the workspace-wide tasks +# never touch it; these two are its build and test entry points. +[tasks."wasm:guest:build"] +description = "Build the stack-encrypt WASI guest module (wasm32-wasip1, release) and assert its host-import surface" +shell = "bash -c" +run = """ +set -euo pipefail +rustup target add wasm32-wasip1 +root=$(pwd) +cd languages/golang/stackencrypt/guest +cargo build --target wasm32-wasip1 --release +module=target/wasm32-wasip1/release/stack_encrypt_guest.wasm +echo "guest module: languages/golang/stackencrypt/guest/$module" +# The security contract is about the *linked* module, which a successful +# build says nothing about: the guest may reach the outside world only +# through the two host functions the Go embedder provides. Fail-closed — +# any other import, or a missing one, fails here rather than widening the +# surface silently. WASI is allowed as a module but denied the +# capability-granting half of its namespace (ambient filesystem and +# sockets), which is the part that would matter if a dependency grew one. +# random_get is required, not merely allowed: the cipher's IVs and nonces +# come from it, and the Go embedder wires it to crypto/rand (wazero's +# default is a fixed seed). If getrandom ever moves to another backend the +# host side must be revisited, so make that a build failure here. +python3 "$root/scripts/check-wasm-imports.py" "$module" \\ + --allow-module wasi_snapshot_preview1 \\ + --deny-prefix wasi_snapshot_preview1:path_ \\ + --deny-prefix wasi_snapshot_preview1:sock_ \\ + --deny-prefix wasi_snapshot_preview1:fd_prestat \\ + --require wasi_snapshot_preview1:random_get \\ + --require cipherstash_transport:transport_send \\ + --require cipherstash_transport:token_get +# The Go module embeds the checked artefact (languages/golang/stackencrypt/wasm, +# gitignored): copying it here is what makes `go test` in the binding run +# against the guest just built rather than a stale one. +cp "$module" ../wasm/stack_encrypt_guest.wasm +echo "embedded into languages/golang/stackencrypt/wasm/stack_encrypt_guest.wasm" +""" + +[tasks."wasm:guest:test"] +description = "Lint and natively test the stack-encrypt WASI guest (ops/config/status modules run on the host target)" +shell = "bash -c" +run = """ +set -euo pipefail +rustup target add wasm32-wasip1 +# The shared guest ABI crate first: a workspace member, so the root lint +# covers its native half, but its export surface (`se_alloc`/`se_dealloc`, +# the packed results) and the transport import compile only for wasm32, and +# a path dependency is not linted from the guest's own workspace below. +cargo clippy -p stack-guest-abi --all-targets --target wasm32-wasip1 -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p stack-guest-abi --target wasm32-wasip1 +cd languages/golang/stackencrypt/guest +cargo fmt --check +cargo clippy --all-targets -- -D warnings +# The wasm32-only modules (abi, host) only compile for the target; lint +# them there so a broken export surface can't hide behind native-only CI. +cargo clippy --target wasm32-wasip1 -- -D warnings +# Intra-doc links, on the target the crate is written for (the wasm32-only +# modules are part of the crate docs). rustdoc only warns on a broken link +# and exits 0, so without -D warnings a stale link ships silently. +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --target wasm32-wasip1 +# nextest, as everywhere else; this crate is a detached workspace, so it +# runs from here rather than a root `-p`. nextest is in mise.test.toml. +mise x --env test -- cargo nextest run +""" + +[tasks."wasm:auth-guest:build"] +description = "Build the credential WASI guest module (stack-profile for Go; wasm32-wasip1, release) and assert its host-import surface" +shell = "bash -c" +run = """ +set -euo pipefail +rustup target add wasm32-wasip1 +root=$(pwd) +cd languages/golang/stackauth/guest +cargo build --target wasm32-wasip1 --release +module=target/wasm32-wasip1/release/stack_auth_guest.wasm +echo "guest module: languages/golang/stackauth/guest/$module" +# The credential guest's contract is the mirror image of the crypto +# guest's: it may reach the filesystem — that is what it is for, and the +# host grants it exactly one directory — and the auth host transport. +# Sockets are denied by name; token exchanges use only the two named host +# imports. random_get is required: the Rust runtime draws through +# it (its hash maps are seeded from it), and the Go side wires it to +# crypto/rand rather than wazero's fixed-seed default. +python3 "$root/scripts/check-wasm-imports.py" "$module" \\ + --allow-module wasi_snapshot_preview1 \\ + --deny-prefix wasi_snapshot_preview1:sock_ \\ + --require wasi_snapshot_preview1:random_get \\ + --require wasi_snapshot_preview1:path_open \\ + --require cipherstash_transport:transport_send \\ + --require cipherstash_transport:oidc_token_get +cp "$module" ../wasm/stack_auth_guest.wasm +echo "embedded into languages/golang/stackauth/wasm/stack_auth_guest.wasm" +""" + +[tasks."wasm:auth-guest:test"] +description = "Lint and natively test the credential WASI guest (ops/status modules run on the host target)" +shell = "bash -c" +run = """ +set -euo pipefail +rustup target add wasm32-wasip1 +cd languages/golang/stackauth/guest +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo clippy --target wasm32-wasip1 -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --target wasm32-wasip1 +mise x --env test -- cargo nextest run +""" + +[tasks."go:test"] +# The old name, from when the module held one package. +alias = "go:stackencrypt:test" +description = "Format check, vet and test the Go module (languages/golang: stackencrypt, stackauth and the internal packages) against the embedded guests; needs `wasm:guest:build` and `wasm:auth-guest:build` first" +# The body lives in scripts/go-binding-test.sh so the macOS and Windows CI +# jobs (which have Go but not mise) run exactly the same checks. One module +# at languages/golang holds every Go package (ADR-0005 §4); the guest it +# embeds is named relative to the module root. +run = "scripts/go-binding-test.sh languages/golang" + +[tasks."go:lint"] +description = "Lint and format-check the Go module (languages/golang) with golangci-lint; config in languages/golang/.golangci.yaml" +dir = "languages/golang" +run = "golangci-lint run ./..." + +[tasks."go:stackencrypt:example"] +description = "Run the stack-encrypt Go example (languages/golang/stackencrypt/example) against real ZeroKMS; needs `stash auth login` first" +shell = "bash -c" +# Both guests: the example reads the profile through stackauth. +depends = ["wasm:guest:build", "wasm:auth-guest:build"] +run = """ +set -euo pipefail +cd languages/golang +# Credentials come from stackencrypt.AutoCredentials: the CS_* variables +# if set, else the developer profile. See stackencrypt/example/README.md. +CGO_ENABLED=0 go run ./stackencrypt/example +""" + +[tasks."go:stackencrypt:example:explicit"] +description = "Run the explicit-credentials Go example (languages/golang/stackencrypt/example/explicit) against real ZeroKMS; pass -secrets-dir, -client-id and -workspace-crn after --" +shell = "bash -c" +depends = ["wasm:guest:build", "wasm:auth-guest:build"] +run = """ +set -euo pipefail +cd languages/golang +# Credentials come only from the flags and the secrets directory: no CS_* +# variables, no profile. See stackencrypt/example/explicit/README.md. +CGO_ENABLED=0 go run ./stackencrypt/example/explicit "$@" +""" diff --git a/package.json b/package.json index 6f22b42a0..1f1f9a4d6 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules", "code:fix": "biome check --write", "code:check": "biome check", + "lint:auth-changeset": "node scripts/lint-no-auth-changeset.mjs", "lint:eql-pins": "node scripts/lint-no-eql-registry-pins.mjs", "lint:package-paths": "node scripts/lint-no-dead-package-paths.mjs", "lint:runners": "node scripts/lint-no-hardcoded-runners.mjs", diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md new file mode 100644 index 000000000..9cab0d081 --- /dev/null +++ b/packages/stack-auth/CHANGELOG.md @@ -0,0 +1,466 @@ + +## [0.42.3] - 2026-08-26 + + +### Features + +- classify usage denials as typed, non-retryable errors + +### Fixes + +- address usage-denial-taxonomy code review findings +- close out remaining PR #2120 review items +- register Notified before dropping the state lock (PR #2120 review) +- decode client-side claims without requiring org_id + +### Refactoring + +- remove duplication flagged by PR #2120 review + +### Testing + +- include org_id in JWT fixtures +- include org_id in JWT fixtures + +## [0.42.2] - 2026-08-17 + + +### Documentation + +- 🩹 correct the fixture comment for the hand-rolled decode + +### Fixes + +- upgrade jsonwebtoken 9→10 (CVE-2026-25537) + +## [0.42.1] - 2026-08-12 + + +### Miscellaneous + +- update Cargo.toml dependencies + +## [0.42.0] - 2026-07-19 + + +### Miscellaneous + +- update Cargo.toml dependencies + +## [0.41.1] - 2026-07-17 + + +### Miscellaneous + +- update Cargo.toml dependencies + +## [0.41.0] - 2026-07-17 + + +### Miscellaneous + +- update Cargo.toml dependencies + +## [0.40.0] - 2026-07-09 + + +### Miscellaneous + +- update Cargo.toml dependencies + + +### Features + +- add AuthError::Custom + from_error_code reconstruction + +### Fixes + +- export CustomError; reconstruct WORKSPACE_MISMATCH from payload + +### Style + +- rustfmt reflow in workspace_mismatch_from_payload + + +### Documentation + +- document the typed-error / diagnostic-help contract +- recommend passing the strategy to an SDK, not getToken +- fix cookies.d.ts example for the Result API +- fix wasm-inline.mjs JSDoc for the Result API +- note the instanceof break in the changelog + +### Features + +- add actionable miette help to AuthError variants +- serialize AuthError across the FFI boundary +- return a Result instead of throwing + +### Fixes + +- resolve doc + CRAP CI gates on error.rs +- wrap napi static factories via facade classes +- update index.d.ts guard for the Result-typed surface +- mirror help/url onto failure.error on the wasm seam +- always brand the JS error with __authFailure +- guard wasm-inline getToken against a synchronous throw +- harden failure envelope against parse + key collision + +### Miscellaneous + +- bump vite in /packages/stack-auth/node +- make changesets adoption review-ready (CIP-3278) +- release as 0.41.0, not 1.0.0 +- bundle the LICENSE in the published package +- drive the 0.41.0 release via changesets + +### Refactoring + +- hand-written index.d.ts re-exporter, drop apply-dts script +- derive .d.ts drift set from AuthError::ERROR_CODES +- decompose AuthError into per-error structs +- adopt AuthError::ERROR_CODES for the AuthFailure drift guards +- define AuthError codes as named constants +- route DeviceClientError through AuthError; tidy payload + +### Testing + +- port re-lock-window cancellation regression test (CIP-3159) +- derive drift-test expected set from error_code() source +- cover all #[diagnostic(help)] variants, not just one +- behavioural guards for the index.d.ts split, not source-text checks +- close coverage gaps in the index.d.ts split guards +- migrate tests, examples and docs to Result; v1.0.0 +- close FFI-envelope coverage gaps from PR review +- guard the __CS_FAIL__ sentinel; clean up temp dir +- cover From<DeviceClientError> for the CRAP gate +- cover WORKSPACE_MISMATCH payload end-to-end + + + +### CI + +- make the CRAP workflow blocking + +### Documentation + +- note OidcFederationStrategy INVALID_CRN error-code change +- drop Rustdoc intra-link from binding doc comments + +### Features + +- add baseUrl override to OidcFederationStrategy (CIP-3246) +- expose base_url override on all auth strategies + +### Fixes + +- format baseUrl bindings + cover baseUrl override in tests + +### Refactoring + +- take a workspace CRN in OidcFederationStrategy +- address code-review findings on the CRN change +- durable index.d.ts additions + shared base_url helper + +### Testing + +- deterministic expiry-crossing refresh test; clear CRAP findings +- cover is_*_at boundaries and failed-refresh expiry path +- assert backwards wall-clock is handled gracefully +- scaffold cargo-fuzz pilot for public string parsers +- assert base_url override beats CS_CTS_HOST +- cover the napi baseUrl seam + the dts normaliser +- close the lopsided baseUrl/normaliser test asymmetries + + +### CI + +- add cargo-crap (CRAP metric) coverage report +- reuse crap:stack-auth mise task in CRAP workflow + + +### Documentation + +- point authorize_dto refresher links at structs + +### Features + +- OidcFederationStrategy — federate a third-party OIDC JWT into a CTS service token +- verify federated token's workspace in OidcFederationStrategy +- napi + wasm bindings for OidcFederationStrategy + +### Miscellaneous + +- adopt biome for JS/TS formatting + CI + +### Refactoring + +- drop audience from OidcFederationStrategy; clarify provider docs +- rename OAuthStrategy to DeviceSessionStrategy + + +### Documentation + +- note InvalidToken alongside WorkspaceMismatch +- add 0.39.0 changelog entry +- refresh AutoStrategy detection-order comment +- note CS_CTS_HOST override on AccessKeyStrategy::new +- align AccessKeyStrategy.create JSDoc + +### Features + +- AccessKeyStrategy takes workspaceCrn, verifies token + +### Fixes + +- clippy unnecessary clones + cipherstash-client test prelude +- address PR review + CI failures + +### Miscellaneous + +- bump @cipherstash/auth to 0.38.0 +- sync lockfile + README to 0.38.0 + +### Refactoring + +- relocate bounds to stack-auth, drop legacy ServiceToken + +### Testing + +- cover WORKSPACE_MISMATCH at FFI boundary +- verify workspace check runs on every get_token call +- reject stored token bound to wrong workspace +- cover AutoStrategy happy path with explicit CRN +- cover AccessKeyStrategy.create happy path +- pin CRN-with-service_name behaviour on AccessKeyStrategy + +### Style + +- apply rustfmt + + + + +### Documentation + +- annotate None literal in TokenStore doctest +- document slick API + cookieStore + /cookies entry + +### Features + +- add TokenStore trait for pluggable token caching +- wire TokenStore into AutoRefresh + AccessKeyStrategy +- AccessKeyStrategy.createWithStore JS-callback bindings +- slick options-object API + cookieStore helper +- add CallbackAuthStrategy for foreign-callback strategies + +### Fixes + +- zeroize JSON-serialised tokens; add assertion messages +- drop private intra-doc link to crate::refresher +- log JsTokenStore callback rejections (CIP-3114) +- scope `web-sys` to the wasm32 target +- Zeroize JsTokenStore JSON + default cookieStore to Secure +- drop redundant self:: link targets in module docs +- address PR #1959 review feedback + +### Miscellaneous + +- migrate napi platform sub-packages to peerDependencies optional +- regenerate index.d.ts; preserve manual AuthError block + +### Refactoring + +- release state mutex during TokenStore load; tighten comments +- extract save_refreshed_token + install_refreshed_token helpers +- rename callback helpers to *Fn, split into auth/store modules + + + + +### Documentation + +- document why TokenResultPayload uses String + +### Features + +- wasm-bindgen sibling crate for Supabase Edge (Layer 3.5) +- make runtime work in Supabase Edge + +### Fixes + +- address Copilot review feedback + patch Dockerfiles + +### Refactoring + +- wasm32 support — cfg-gate filesystem and Send bounds +- 🚨 address review feedback on Layer 3 PR +- dedupe error code mapping + tighten bindings +- scope down to AccessKeyStrategy + + + +### Miscellaneous + +- release v0.34.1-alpha.2 + + +### Miscellaneous + +- release +- use explicit versions for cipherstash-client and stack-auth + + +### Miscellaneous + +- updated the following local packages: cts-common, cts-common, stack-profile, zerokms-protocol + + +### Documentation + +- 📝 add TypeScript example for AutoStrategy usage +- 📝 add CHANGELOG.md for @cipherstash/auth +- 📝 add INVALID_CRN to changelog error codes +- 📝 demonstrate whoami (subject/workspace) in examples +- 📝 update CHANGELOG with whoami fields and security notes + +### Features + +- ✨ expose auth strategies in @cipherstash/auth Node bindings +- ✨ add subject() and workspace_id() to ServiceToken +- add multi-workspace profile support (CIP-2942) +- require workspace to exist before switching + +### Fixes + +- 🩹 add INVALID_CRN error code and deduplicate zerokms_url +- 🔒️ derive OpaqueDebug on TokenResult to prevent token leaks +- 🔒️ derive OpaqueDebug on AutoStrategyOptions +- update integration tests for workspace-scoped profiles +- hard-error on token persistence failure, strengthen test assertions +- use npm install instead of npm ci in integration test tasks + +### Miscellaneous + +- 🔖 bump @cipherstash/auth to 0.35.0 +- 🔧 regenerate index.d.ts from napi build +- release + +### Refactoring + +- ♻️ restructure stack-auth-node tests to follow conventions +- simplify workspace store usage + +### Testing + +- ✅ add unit tests for exposed auth strategies + +### Style + +- 💄 fix cargo fmt formatting +- 🎨 remove redundant comments from examples + + +### Documentation + +- 📝 add TypeScript example for AutoStrategy usage +- 📝 add CHANGELOG.md for @cipherstash/auth +- 📝 add INVALID_CRN to changelog error codes +- 📝 demonstrate whoami (subject/workspace) in examples +- 📝 update CHANGELOG with whoami fields and security notes + +### Features + +- ✨ expose auth strategies in @cipherstash/auth Node bindings +- ✨ add subject() and workspace_id() to ServiceToken + +### Fixes + +- 🩹 add INVALID_CRN error code and deduplicate zerokms_url +- 🔒️ derive OpaqueDebug on TokenResult to prevent token leaks +- 🔒️ derive OpaqueDebug on AutoStrategyOptions + +### Miscellaneous + +- 🔖 bump @cipherstash/auth to 0.35.0 +- 🔧 regenerate index.d.ts from napi build + +### Refactoring + +- ♻️ restructure stack-auth-node tests to follow conventions + +### Testing + +- ✅ add unit tests for exposed auth strategies + +### Style + +- 💄 fix cargo fmt formatting +- 🎨 remove redundant comments from examples +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +### Features + +- add provisionDeviceClient Node.js binding and tests + +### Fixes + +- lock file +- add User-Agent header, rename to device_client, surface errors + +### Miscellaneous + +- clean up test imports and simplify mise task + +### Refactoring + +- extract device client provisioning from CLI into stack-auth +- rename provisionDeviceClient to bindClientDevice + + +### Documentation + +- add README for stack-auth and include it as module docs +- add README for @cipherstash/auth npm package + +### Fixes + +- remove blank line to satisfy cargo fmt +- update vitaminc imports for 0.1.0-pre4.2 module restructure + + +### Documentation + +- 📝 move token refresh docs and mermaid diagram to public AuthStrategy trait + +### Fixes + +- 🐛 fix race condition in get_token() when token expires during refresh + +### Testing + +- ✅ restructure auto_refresh tests into nested scenario modules + + +### Documentation + +- 📝 fix AutoStrategy docs to reference CS_WORKSPACE_CRN not CS_REGION + +### Features + +- add AutoStrategyBuilder, Option<T> KeyProvider, and SecretKey::from_hex + +### Fixes + +- 🔥 remove unreleased AutoStrategy::new() deprecated method +- 🩹 remove unnecessary bytes.clone() and improve MissingWorkspaceCrn message +- 🩹 address PR review feedback + +### Refactoring + +- ♻️ replace with_region with with_workspace_crn and add diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml new file mode 100644 index 000000000..e004b61c7 --- /dev/null +++ b/packages/stack-auth/Cargo.toml @@ -0,0 +1,99 @@ +[package] +name = "stack-auth" +description = "Authentication library for CipherStash services" +version = "0.42.3" +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +license-file = "LICENSE" + +# `aquamarine` is only used by a `#[doc]` macro in a doctest. `cargo-udeps` +# cannot see into rustdoc, so it reports `aquamarine` as unused. Ignore it. +[package.metadata.cargo-udeps.ignore] +normal = ["aquamarine"] + +[dependencies] +aquamarine = "0.6" +base64 = { workspace = true } +cts-common = { workspace = true } +miette = { workspace = true } +reqwest = { workspace = true, optional = true } +# `Bytes::from_owner` lends reqwest a buffer this crate still owns, so the +# request body is wiped when reqwest is done rather than copied. +bytes = { version = "1.9", optional = true } +serde = { workspace = true } +serde_json = { workspace = true } +# Form bodies for the OAuth token endpoints, encoded exactly as reqwest's +# `.form()` does (it uses this crate), so the bundled transport and a host's +# own transport send byte-identical requests. +serde_urlencoded = "0.7" +stack-profile = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } +vitaminc = { workspace = true, features = ["protected"] } +vitaminc-protected = { workspace = true } +web-time = { workspace = true } +zerokms-protocol = { workspace = true } +zeroize = { workspace = true } + +# `stack-profile` is shared above: the WASI credential guest reads and saves +# device-session tokens through it, while the native CLI holds its own lock. +# Native-only: +# - `open` launches a browser for device-code auth +# - `jsonwebtoken` pulls `ring`, which doesn't compile on wasm32; it is now used +# only by native tests (to mint fixture JWTs). Claim decoding itself no longer +# uses it — see `decode_jwt_payload`. +# - `tokio` with `full` features pulls `mio` (network IO), which doesn't +# compile on wasm32. Workspace dep is `features = ["full"]` so we can't +# subtract — split target-conditionally instead. +# +# JWT claim decoding uses a manual base64+JSON path (`decode_jwt_payload`) on +# every target — `base64` above is shared. Wasm consumers can also use +# `DeviceSessionStrategy::with_workspace_store` when their host holds the lock. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +open = "5.3.2" +jsonwebtoken = { workspace = true } +tokio = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +tokio = { version = "1.47.1", default-features = false, features = ["sync"] } + +[features] +default = ["http"] +# The bundled HTTP transport: `ReqwestTransport`, which every strategy +# builder uses unless given another `HttpTransport`. Off, reqwest and its +# native TLS stack are not in the dependency graph at all — the shape a host +# with its own transport (the WASI/wazero guest) builds against — and the +# strategies (access-key, device-session, OIDC-federation, auto) still exist +# but must be handed a transport. Device binding and the device-code flow are +# native-only and keep the bundled transport, so they need this feature. +# `StaticTokenStrategy` is a test double behind `test-utils`, not part of +# the production surface. +http = ["dep:reqwest", "dep:bytes"] +test-utils = [] +# Exposes fuzz-only entry points (e.g. `fuzz_decode_claims`) for the cargo-fuzz +# harnesses in `fuzz/`. A Cargo feature (not `#[cfg(fuzzing)]`) so it is a known +# cfg and never trips the `unexpected_cfgs` lint under `-D warnings`. +fuzz = [] + +[[example]] +name = "auto_strategy" +required-features = ["http"] + +[[example]] +name = "device_code" +required-features = ["http", "test-utils"] + +[dev-dependencies] +axum = "0.8" +cts-common = { workspace = true } +mocktail = "0.3.0" +# Version matches the existing use in `vitur-server-core`. +proptest = "1.7.0" +temp-env = { workspace = true } +tempfile = "3.21.0" +tokio = { workspace = true, features = ["test-util"] } +tracing-subscriber = { workspace = true } diff --git a/packages/stack-auth/LICENSE b/packages/stack-auth/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-auth/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + +<https://polyformproject.org/licenses/internal-use/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-auth/README.md b/packages/stack-auth/README.md new file mode 100644 index 000000000..8ffeee072 --- /dev/null +++ b/packages/stack-auth/README.md @@ -0,0 +1,186 @@ +# stack-auth + +[![Crates.io Version](https://img.shields.io/crates/v/stack-auth?style=for-the-badge)](https://crates.io/crates/stack-auth) +[![docs.rs](https://img.shields.io/docsrs/stack-auth?style=for-the-badge)](https://docs.rs/stack-auth/) +[![Built by CipherStash](https://raw.githubusercontent.com/cipherstash/meta/refs/heads/main/csbadge.svg)](https://cipherstash.com) + + [Website](https://cipherstash.com) | [Docs](https://cipherstash.com/docs) | [Discord](https://discord.com/invite/5qwXUFb6PB) + +Authentication strategies for [CipherStash](https://cipherstash.com) services. + +All strategies implement the [`AuthStrategy`] trait, which provides a single +[`get_token`](AuthStrategy::get_token) method that returns a valid +[`ServiceToken`]. Token caching and refresh are handled automatically. + +## Strategies + +| Strategy | Use case | Credentials | +|---|---|---| +| [`AutoStrategy`] | Recommended default — detects credentials automatically | `CS_CLIENT_ACCESS_KEY` + `CS_WORKSPACE_CRN`, or `~/.cipherstash/auth.json` | +| [`AccessKeyStrategy`] | Service-to-service / CI | Static access key + workspace CRN | +| [`DeviceSessionStrategy`] | Long-lived sessions with refresh | OAuth token (from device code flow or disk) | +| [`DeviceCodeStrategy`] | CLI login ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) | User authorizes in browser | +| `StaticTokenStrategy` | Tests only (`test-utils` feature) | Pre-obtained token used as-is | + +## Quick start + +For most applications, [`AutoStrategy`] is the simplest way to get started: + +```no_run +use stack_auth::AutoStrategy; + +# async fn run() -> Result<(), Box<dyn std::error::Error>> { +let strategy = AutoStrategy::detect()?; +// That's it — get_token() handles the rest. +# Ok(()) +# } +``` + +For service-to-service authentication with an access key: + +```no_run +use stack_auth::AccessKeyStrategy; +use cts_common::Crn; + +# fn run() -> Result<(), Box<dyn std::error::Error>> { +let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse()?; +let key = "CSAKkeyId.keySecret".parse()?; +let strategy = AccessKeyStrategy::new(crn, key)?; +# Ok(()) +# } +``` + +## Error handling + +Every fallible operation returns [`AuthError`], a structured enum in which +each variant wraps a dedicated error struct. Errors are designed to tell the +developer exactly what to do next: + +- **Stable machine-readable codes** — [`AuthError::error_code`] returns a + `SCREAMING_CASE` identifier (e.g. `NOT_AUTHENTICATED`, + `WORKSPACE_MISMATCH`) suitable for logs, metrics, and programmatic + handling. The same taxonomy crosses the FFI boundary: the + [`@cipherstash/auth`](https://www.npmjs.com/package/@cipherstash/auth) npm + package surfaces these codes as the `type` discriminant of its typed + `AuthFailure` union. +- **Actionable help** — every variant implements + [`miette::Diagnostic`](https://docs.rs/miette), so `help()` (and `url()` + when present) carry remediation guidance, e.g. `NOT_AUTHENTICATED` says + ``Log in with `stash login`, or set `CS_CLIENT_ACCESS_KEY` for + service-to-service auth.`` Applications that render errors through miette + (like the Stash CLI) show this automatically. +- **Structured payload** — variants carry typed fields rather than + pre-formatted strings; e.g. [`WorkspaceMismatch`] exposes + `expected_workspace` and `token_workspace` so callers can act on the + values, not parse a message. + +```no_run +use miette::Diagnostic; +use stack_auth::{AuthError, AutoStrategy}; + +fn report(err: &AuthError) { + eprintln!("[{}] {err}", err.error_code()); + if let Some(help) = err.help() { + eprintln!(" help: {help}"); + } + if let Some(url) = err.url() { + eprintln!(" more: {url}"); + } + if let AuthError::WorkspaceMismatch(m) = err { + eprintln!( + " token belongs to {}, strategy expects {}", + m.token_workspace, m.expected_workspace + ); + } +} + +match AutoStrategy::detect() { + Ok(_strategy) => { /* authenticated */ } + Err(err) => report(&err), +} +``` + +## Extensibility + +`stack-auth` exposes two layers that can be plugged independently: + +```text + ┌──────────────────────────────────────────────────┐ + │ AuthStrategy ─ acquisition layer │ + │ get_token() -> ServiceToken │ + │ AccessKeyStrategy / DeviceSessionStrategy / AutoStrategy│ + │ ── or ── │ + │ AuthStrategyFn (closure → AuthStrategy) │ + └────────────────────────┬─────────────────────────┘ + │ uses + ┌────────────────────────▼─────────────────────────┐ + │ TokenStore ─ persistence layer │ + │ load() / save() of Token │ + │ InMemoryTokenStore / NoStore │ + │ ── or ── │ + │ TokenStoreFn (closures → TokenStore) │ + └──────────────────────────────────────────────────┘ +``` + +Use [`TokenStoreFn`] when you want stack-auth's own strategies to handle +HTTP/refresh, but you need to plug in custom **persistence** (a cookie, +a KV blob, Redis). Wire it via the strategy's builder. + +Use [`AuthStrategyFn`] when you want to bring your own **token acquisition** +end-to-end — typically because the strategy lives across an FFI boundary +(e.g. a JS `getToken()` reached via `protect-ffi`). The closure runs every +time a token is needed. + +Beneath both sits the **transport**: every strategy sends its requests +through an [`HttpTransport`], and the bundled [`ReqwestTransport`] is only +the default. Implement the trait to run the real strategies over an HTTP +client that is not reqwest — a host runtime's, or a stub in a test — and hand +it to the strategy's builder. The trait is one method, in the shape of a +plain request and response: + +```rust,no_run +use stack_auth::{AccessKey, AccessKeyStrategy, HttpRequest, HttpResponse, HttpTransport, RequestError}; +use cts_common::Crn; + +struct MyTransport; + +impl HttpTransport for MyTransport { + async fn send(&self, request: HttpRequest) -> Result<HttpResponse, RequestError> { + // `request.method()`, `request.url()`, `request.headers()`, `request.body()` + let (status, headers, body) = my_http_client(request).await?; + Ok(HttpResponse::new(status, headers, body)) + } +} +# async fn my_http_client(_: HttpRequest) -> Result<(u16, Vec<(String, String)>, Vec<u8>), RequestError> { unimplemented!() } + +# fn run() -> Result<(), Box<dyn std::error::Error>> { +let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse()?; +let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse()?; +let strategy = AccessKeyStrategy::builder(crn, key) + .transport(MyTransport) + .build()?; +# Ok(()) +# } +``` + +One transport can serve several strategies: `Arc<T>` implements the trait +whenever `T` does, so hand each builder a clone of the `Arc`. Without the +`http` feature there is no bundled transport, so `.transport(..)` is required +rather than optional; nothing else about the strategies changes. + +Module paths mirror this split: [`stack_auth::auth`](crate::auth) groups the +acquisition layer, [`stack_auth::store`](crate::store) groups the persistence +layer. All items are also re-exported at the crate root. + +## Security + +Sensitive values ([`SecretToken`]) are automatically zeroized when dropped +and are masked in [`Debug`](std::fmt::Debug) output to prevent accidental +leaks in logs. + +## Token refresh + +All strategies that cache tokens ([`AccessKeyStrategy`], [`DeviceSessionStrategy`], +[`AutoStrategy`]) share the same internal refresh engine. See the +[`AuthStrategy`] trait docs for a full description of the concurrency model +and flow diagram. diff --git a/packages/stack-auth/examples/auto_strategy.rs b/packages/stack-auth/examples/auto_strategy.rs new file mode 100644 index 000000000..5815fc986 --- /dev/null +++ b/packages/stack-auth/examples/auto_strategy.rs @@ -0,0 +1,53 @@ +//! Demonstrates automatic credential detection with [`AutoStrategy`]. +//! +//! `AutoStrategy` picks the best available authentication method without +//! requiring the caller to choose one explicitly. It checks for credentials +//! in the following order: +//! +//! 1. **Access key** – if `CS_CLIENT_ACCESS_KEY` is set along with +//! `CS_WORKSPACE_CRN`, an [`AccessKeyStrategy`] is used. +//! 2. **OAuth** – if a token store file exists at `~/.cipherstash/auth.json` +//! (written by `stash login`), a [`DeviceSessionStrategy`] is used. +//! 3. If neither is available, an error is returned. +//! +//! # Running the example +//! +//! With an access key: +//! +//! ```sh +//! CS_CLIENT_ACCESS_KEY=<key> CS_WORKSPACE_CRN=<crn> cargo run --example auto_strategy +//! ``` +//! +//! Or after authenticating via the CLI: +//! +//! ```sh +//! stash login +//! cargo run --example auto_strategy +//! ``` + +use stack_auth::{AuthStrategy, AutoStrategy}; + +#[tokio::main] +async fn main() -> Result<(), Box<dyn std::error::Error>> { + tracing_subscriber::fmt::init(); + + // AutoStrategy detects credentials automatically: + // + // 1. CS_CLIENT_ACCESS_KEY env var → AccessKeyStrategy + // 2. ~/.cipherstash/auth.json file → DeviceSessionStrategy + // 3. Neither → error + let strategy = AutoStrategy::detect()?; + + match &strategy { + AutoStrategy::AccessKey(_) => println!("Using access key authentication"), + AutoStrategy::DeviceSession(_) => println!("Using device-session (OAuth) authentication"), + } + + // Obtain a token — refresh happens automatically when needed. + let token = (&strategy).get_token().await?; + println!("Subject: {}", token.subject()?); + println!("Workspace: {}", token.workspace_id()?); + println!("Issuer: {}", token.issuer()?); + + Ok(()) +} diff --git a/packages/stack-auth/examples/device_code.rs b/packages/stack-auth/examples/device_code.rs new file mode 100644 index 000000000..1fd1e7274 --- /dev/null +++ b/packages/stack-auth/examples/device_code.rs @@ -0,0 +1,32 @@ +use cts_common::Region; +use stack_auth::DeviceCodeStrategy; + +#[tokio::main] +async fn main() -> Result<(), Box<dyn std::error::Error>> { + tracing_subscriber::fmt::init(); + + let region = Region::aws("ap-southeast-2")?; + let strategy = DeviceCodeStrategy::builder(region, "cli") + .base_url("http://localhost:3001".parse()?) + .build()?; + + // Step 1: Begin the device code flow + let pending = strategy.begin().await?; + + // Step 2: Display the code and open the browser (caller controls this) + println!("Your code is: {}", pending.user_code()); + println!("Visit: {}", pending.verification_uri_complete()); + + if !pending.open_in_browser() { + eprintln!("Could not open browser — please visit the URL above manually."); + } + + // Step 3: Poll until the user authorizes + let token = pending.poll_for_token().await?; + + println!("Token type: {}", token.token_type()); + println!("Expires in: {}s", token.expires_in()); + println!("Access token: {:?}", token.access_token()); + + Ok(()) +} diff --git a/packages/stack-auth/fuzz/Cargo.lock b/packages/stack-auth/fuzz/Cargo.lock new file mode 100644 index 000000000..75797cb9c --- /dev/null +++ b/packages/stack-auth/fuzz/Cargo.lock @@ -0,0 +1,4075 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", +] + +[[package]] +name = "async-compression" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cached" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.5", + "once_cell", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cipherstash-config" +version = "0.42.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d098935e395d7346d0cdc8cdf3ed9674ab03fa8b415e828d02e65c81836a73c" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "const-hex" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cts-common" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cb0f5ffa463e8facbe6ad78cfe925d132a051c6b1c9a5da2f3961296b7e632" +dependencies = [ + "arrayvec", + "base32", + "cached", + "chrono", + "derive_more", + "either", + "getrandom 0.4.2", + "miette", + "nom", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "utoipa", + "uuid", + "vitaminc", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", + "ctutils", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dummy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fake" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" +dependencies = [ + "deunicode", + "dummy", + "rand 0.8.6", + "uuid", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix", + "windows-targets 0.52.6", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.1", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +dependencies = [ + "serde", +] + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.0", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "mutants" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.3", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.0", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "hickory-resolver", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.21.1", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.42.3" +dependencies = [ + "aquamarine", + "base64", + "bytes", + "cts-common", + "jsonwebtoken", + "miette", + "open", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "stack-profile", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "web-time", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-auth-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "stack-auth", +] + +[[package]] +name = "stack-profile" +version = "0.42.3" +dependencies = [ + "dirs", + "gethostname", + "serde", + "serde_json", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "url", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "atomic", + "getrandom 0.3.4", + "js-sys", + "md-5", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vitaminc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +dependencies = [ + "vitaminc-aead", + "vitaminc-context", + "vitaminc-encrypt", + "vitaminc-protected", + "vitaminc-random", + "vitaminc-traits", +] + +[[package]] +name = "vitaminc-aead" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +dependencies = [ + "bytes", + "serde", + "vitaminc-aead-derive", + "vitaminc-context", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-context" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +dependencies = [ + "mutants", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.18", + "vitaminc-protected-derive", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-random" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random-derives", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-traits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerokms-protocol" +version = "0.12.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c28e88315a5109d0a1e7ee4b7b4b8776a0bff5f5b139ae83960a3debe84e92e" +dependencies = [ + "base64", + "cipherstash-config", + "const-hex", + "cts-common", + "fake", + "getrandom 0.2.17", + "opaque-debug", + "rand 0.8.6", + "serde", + "static_assertions", + "thiserror 1.0.69", + "utoipa", + "uuid", + "validator", + "zeroize", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/packages/stack-auth/fuzz/Cargo.toml b/packages/stack-auth/fuzz/Cargo.toml new file mode 100644 index 000000000..a7578638a --- /dev/null +++ b/packages/stack-auth/fuzz/Cargo.toml @@ -0,0 +1,40 @@ +# Fuzz crate for stack-auth's public string parsers. +# +# This is a DETACHED crate: the `[workspace]` table at the bottom makes it its +# own workspace root so the libfuzzer-sys dependency and the nightly-only build +# never touch the main monorepo workspace. It is not a member of the root +# workspace (see the root Cargo.toml `members` list). Run via the `fuzz:*` +# mise tasks, which invoke `cargo +nightly fuzz run`. +[package] +name = "stack-auth-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.stack-auth] +path = ".." +# `fuzz` exposes the fuzz-only entry points (e.g. `Token::fuzz_decode_claims`). +features = ["fuzz"] + +[[bin]] +name = "access_key_parse" +path = "fuzz_targets/access_key_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "jwt_decode" +path = "fuzz_targets/jwt_decode.rs" +test = false +doc = false +bench = false + +[workspace] +resolver = "2" diff --git a/packages/stack-auth/fuzz/corpus/access_key_parse/valid-access-key b/packages/stack-auth/fuzz/corpus/access_key_parse/valid-access-key new file mode 100644 index 000000000..ce77a49b7 --- /dev/null +++ b/packages/stack-auth/fuzz/corpus/access_key_parse/valid-access-key @@ -0,0 +1 @@ +CSAKkeyid01.secret-value-here \ No newline at end of file diff --git a/packages/stack-auth/fuzz/corpus/jwt_decode/valid-jwt b/packages/stack-auth/fuzz/corpus/jwt_decode/valid-jwt new file mode 100644 index 000000000..dda13b77f --- /dev/null +++ b/packages/stack-auth/fuzz/corpus/jwt_decode/valid-jwt @@ -0,0 +1 @@ +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2N0cy5leGFtcGxlLmNvbS8iLCJzdWIiOiJDU3x0ZXN0LXVzZXIiLCJhdWQiOiJ0ZXN0LWF1ZGllbmNlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDAsIndvcmtzcGFjZSI6IlpWQVRLVzNWSE1GRzI3RFkiLCJzY29wZSI6IiJ9.c2lnbmF0dXJl \ No newline at end of file diff --git a/packages/stack-auth/fuzz/fuzz_targets/access_key_parse.rs b/packages/stack-auth/fuzz/fuzz_targets/access_key_parse.rs new file mode 100644 index 000000000..c90fb425e --- /dev/null +++ b/packages/stack-auth/fuzz/fuzz_targets/access_key_parse.rs @@ -0,0 +1,11 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +// Fuzz the public `AccessKey` string parser (`CSAK<key_id>.<key_secret>`). +// libfuzzer-sys supplies `&str` via the `arbitrary` crate. Access keys are +// untrusted credential strings supplied by callers, so parsing them must never +// panic — malformed input must return `Err(InvalidAccessKey)`, not crash. +fuzz_target!(|s: &str| { + let _ = s.parse::<stack_auth::AccessKey>(); +}); diff --git a/packages/stack-auth/fuzz/fuzz_targets/jwt_decode.rs b/packages/stack-auth/fuzz/fuzz_targets/jwt_decode.rs new file mode 100644 index 000000000..10279c79d --- /dev/null +++ b/packages/stack-auth/fuzz/fuzz_targets/jwt_decode.rs @@ -0,0 +1,14 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +// Fuzz the JWT claims decode path on arbitrary UTF-8. stack-auth reads claims +// from tokens it already holds without verifying the signature, so the decoder +// must never panic on a malformed token — only return `Err`. +// `Token::fuzz_decode_claims` is a `fuzz`-feature-gated entry point that runs +// the real decode and discards the claims. Decoding uses the hand-rolled +// base64/JSON path (`decode_jwt_payload`) on every target now, so this exercises +// the same code that runs in production. +fuzz_target!(|s: &str| { + let _ = stack_auth::Token::fuzz_decode_claims(s); +}); diff --git a/packages/stack-auth/src/access_key.rs b/packages/stack-auth/src/access_key.rs new file mode 100644 index 000000000..cef3285eb --- /dev/null +++ b/packages/stack-auth/src/access_key.rs @@ -0,0 +1,149 @@ +use std::str::FromStr; + +use crate::SecretToken; +use vitaminc::protected::OpaqueDebug; + +/// The prefix that all CipherStash access keys start with. +const ACCESS_KEY_PREFIX: &str = "CSAK"; + +/// A CipherStash access key. +/// +/// Access keys have the format `CSAK<key_id>.<key_secret>` and are used to +/// authenticate with the CipherStash Token Service (CTS). +/// +/// The inner value is stored as a [`SecretToken`], so it is zeroized on drop +/// and hidden from debug output. +/// +/// # Parsing +/// +/// ``` +/// use stack_auth::AccessKey; +/// +/// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); +/// ``` +/// +/// Invalid keys are rejected: +/// +/// ``` +/// use stack_auth::AccessKey; +/// +/// assert!("not-a-valid-key".parse::<AccessKey>().is_err()); +/// assert!("CSAKmissing-dot".parse::<AccessKey>().is_err()); +/// assert!("CSAK.no-key-id".parse::<AccessKey>().is_err()); +/// assert!("CSAKno-secret.".parse::<AccessKey>().is_err()); +/// ``` +#[derive(OpaqueDebug)] +pub struct AccessKey(SecretToken); + +impl AccessKey { + /// Expose the underlying [`SecretToken`]. + pub(crate) fn into_secret_token(self) -> SecretToken { + self.0 + } +} + +// NOTE: The format validation here mirrors `UnverifiedAccessKey::new()` in +// `cts-domain`. If the `CSAK<key_id>.<key_secret>` format changes, both +// locations must be updated. +impl FromStr for AccessKey { + type Err = InvalidAccessKey; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let rest = s + .strip_prefix(ACCESS_KEY_PREFIX) + .ok_or(InvalidAccessKey::MissingPrefix)?; + + let (id, secret) = rest.split_once('.').ok_or(InvalidAccessKey::MissingDot)?; + + if id.is_empty() { + return Err(InvalidAccessKey::EmptyKeyId); + } + if secret.is_empty() { + return Err(InvalidAccessKey::EmptySecret); + } + + Ok(Self(SecretToken::new(s))) + } +} + +/// Error returned when parsing an invalid access key string. +#[derive(Debug, thiserror::Error)] +pub enum InvalidAccessKey { + /// The string does not start with the `CSAK` prefix. + #[error("access key must start with \"{ACCESS_KEY_PREFIX}\"")] + MissingPrefix, + /// No `.` separator found between key ID and secret. + #[error("access key must contain a \".\" separator")] + MissingDot, + /// The key ID portion (before the `.`) is empty. + #[error("access key ID must not be empty")] + EmptyKeyId, + /// The secret portion (after the `.`) is empty. + #[error("access key secret must not be empty")] + EmptySecret, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_key() { + let key: AccessKey = + "CSAKT4ZMT2AUPXI7TCD2.ZAQRW2BWXP3Z6SHR4YG2TP3N35LLU46ZAWLR3BL5WUR4IIGA" + .parse() + .unwrap(); + assert_eq!( + key.0.as_str(), + "CSAKT4ZMT2AUPXI7TCD2.ZAQRW2BWXP3Z6SHR4YG2TP3N35LLU46ZAWLR3BL5WUR4IIGA" + ); + } + + #[test] + fn missing_prefix() { + let err = "key_id.key_secret".parse::<AccessKey>().unwrap_err(); + assert!(matches!(err, InvalidAccessKey::MissingPrefix)); + } + + #[test] + fn missing_dot() { + let err = "CSAKnodot".parse::<AccessKey>().unwrap_err(); + assert!(matches!(err, InvalidAccessKey::MissingDot)); + } + + #[test] + fn empty_key_id() { + let err = "CSAK.secret".parse::<AccessKey>().unwrap_err(); + assert!(matches!(err, InvalidAccessKey::EmptyKeyId)); + } + + #[test] + fn empty_secret() { + let err = "CSAKid.".parse::<AccessKey>().unwrap_err(); + assert!(matches!(err, InvalidAccessKey::EmptySecret)); + } + + #[test] + fn empty_string() { + let err = "".parse::<AccessKey>().unwrap_err(); + assert!(matches!(err, InvalidAccessKey::MissingPrefix)); + } + + #[test] + fn into_secret_token() { + let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); + let secret = key.into_secret_token(); + assert_eq!(secret.as_str(), "CSAKmyKeyId.myKeySecret"); + } + + #[test] + fn debug_does_not_leak() { + let key: AccessKey = "CSAKid.secret".parse().unwrap(); + let debug = format!("{key:?}"); + assert!(!debug.contains("secret")); + assert!( + debug.contains("AccessKey") && debug.contains("***"), + "debug should hide secret: {debug}" + ); + } +} diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs new file mode 100644 index 000000000..54cfe0698 --- /dev/null +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -0,0 +1,1320 @@ +use url::Url; + +use crate::authorize_dto::AuthoriseResponse; +use crate::refresher::Refresher; +use crate::transport::{self, SharedTransport}; +use crate::{AuthError, SecretToken, Token}; + +/// A [`Refresher`] that uses a static access key to authenticate. +/// +/// Unlike OAuth, the access key never changes — `try_credential` always returns +/// `Some(())` and `restore` is a no-op. This means `AutoRefresh` can perform +/// initial authentication on the first `get_token()` call (cold start). +pub(crate) struct AccessKeyRefresher { + access_key: SecretToken, + base_url: Url, + audience: Option<String>, + transport: SharedTransport, +} + +impl AccessKeyRefresher { + pub(crate) fn new( + access_key: SecretToken, + base_url: Url, + audience: Option<String>, + transport: SharedTransport, + ) -> Self { + Self { + access_key, + base_url, + audience, + transport, + } + } +} + +impl Refresher for AccessKeyRefresher { + type Credential = (); + + fn save(&self, _token: &Token) { + // Access key tokens are ephemeral — no persistence needed. + } + + fn try_credential(&self, _token: Option<&mut Token>) -> Option<Self::Credential> { + Some(()) + } + + fn restore(&self, _token: &mut Token, _credential: Self::Credential) { + // Nothing to restore — the access key is always available. + } + + async fn refresh(&self, _credential: &Self::Credential) -> Result<Token, AuthError> { + let url = self.base_url.join("api/authorise")?; + + tracing::debug!(url = %url, "authenticating with access key"); + + let resp = transport::post_json( + &self.transport, + url, + &AuthoriseRequest { + access_key: self.access_key.as_str(), + audience: self.audience.as_deref(), + }, + ) + .await?; + + if !resp.is_success() { + let status = resp.status(); + let body = resp.text(); + tracing::debug!(%status, %body, "access key auth failed"); + if let Some(err) = crate::error::classify_issuance_failure(status, &body) { + return Err(err); + } + return Err(AuthError::Server(crate::error::ServerError(format!( + "{status}: {body}" + )))); + } + + let auth_resp: AuthoriseResponse = resp.json()?; + + // The response → Token mapping (including the absolute-epoch `expiry` + // handling that CIP-3233 fixed) lives on `From<AuthoriseResponse>`. + Ok(auth_resp.into()) + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct AuthoriseRequest<'a> { + access_key: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + audience: Option<&'a str>, +} + +#[cfg(test)] +#[cfg(feature = "http")] +mod tests { + use super::*; + use crate::auto_refresh::{AutoRefresh, AutoRefreshError}; + use crate::transport::default_transport; + use crate::TokenStore; + use mocktail::prelude::*; + use std::sync::Arc; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// Build a mock `/api/authorise` response. CTS returns `expiry` as an + /// ABSOLUTE Unix epoch (the JWT `exp` claim), so model that faithfully: the + /// token is valid for `expires_in_secs` from now. + fn auth_response_json(access: &str, expires_in_secs: u64) -> serde_json::Value { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + serde_json::json!({ + "accessToken": access, + "expiry": now + expires_in_secs + }) + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("access-key-refresher-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + fn make_access_key_strategy(server: &MockServer) -> AutoRefresh<AccessKeyRefresher> { + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + Some("test-audience".to_string()), + default_transport(), + ); + AutoRefresh::with_store(refresher, crate::NoStore) + } + + /// Build a `Token` whose `expires_at` is `expires_in_secs` from now — + /// pass `0` for "already expired", `3600` for "fresh, well outside the + /// 90s expiry-leeway window". + fn make_token(access: &str, expires_in_secs: u64) -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + Token { + access_token: SecretToken::new(access), + token_type: "Bearer".to_string(), + expires_at: now + expires_in_secs, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + } + + fn make_expired_token(access: &str) -> Token { + make_token(access, 0) + } + + fn make_fresh_token(access: &str) -> Token { + make_token(access, 3600) + } + + // ---- Regression: CTS `expiry` is an absolute epoch (CIP-3233) ---- + + /// CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (the JWT + /// `exp` claim), not a relative duration. The refresher must use it as-is. + /// + /// Pre-fix (`expires_at = now + expiry`), this token's `expires_at` lands + /// ~decades in the future, so `is_expired()` is never true — the token never + /// refreshes and silently dies at its real ~15-minute `exp`. The assertion + /// below fails under the pre-fix arithmetic (`expires_in()` ≈ 1.7e9) and + /// passes with the fix (`expires_in()` ≈ 900). + #[tokio::test] + async fn access_key_expiry_is_absolute_epoch_not_relative() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let absolute_expiry = now + 900; // a 15-minute token, as an absolute epoch + + let mut mocks = MockSet::new(); + mocks.mock(move |when, then| { + when.post().path("/api/authorise"); + then.json(serde_json::json!({ + "accessToken": "tok", + "expiry": absolute_expiry + })); + }); + let server = start_server(mocks).await; + + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + server.url(""), + None, + default_transport(), + ); + let token = refresher.refresh(&()).await.unwrap(); + + assert!( + token.expires_in() <= 1000, + "expires_in should be ~900s (absolute `expiry` used as-is); got {} \ + — pre-fix `now + expiry` yields ~1.7e9", + token.expires_in() + ); + assert!( + !token.is_expired(), + "a fresh 15-minute token must not be reported as already expired" + ); + } + + // ---- Initial auth tests ---- + + #[tokio::test] + async fn test_initial_auth_no_cached_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("new-token", 3600)); + }); + let server = start_server(mocks).await; + let strategy = make_access_key_strategy(&server); + + let token = strategy.get_token().await.unwrap(); + + assert_eq!(token.as_str(), "new-token"); + } + + /// A usage denial must not arrive as `SERVER_ERROR`. Clients treat that as + /// transient and retry — but no amount of retrying clears a usage limit, so + /// they would spin until the plan changes. + #[tokio::test] + async fn usage_limit_402_is_typed_not_server_error() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.status(reqwest::StatusCode::PAYMENT_REQUIRED).json(serde_json::json!({ + "error": "usage_limit_exceeded", + "error_description": "Workspace has exceeded its usage limit and cannot issue an access token", + })); + }); + let server = start_server(mocks).await; + let strategy = make_access_key_strategy(&server); + + let err = strategy + .get_token() + .await + .expect_err("402 must fail the token request"); + + let auth_err = match err { + AutoRefreshError::Auth(e) => e, + other => panic!("expected an auth error, got {other:?}"), + }; + assert_eq!(auth_err.error_code(), "USAGE_LIMIT_EXCEEDED"); + assert!( + auth_err.to_string().contains("exceeded its usage limit"), + "server's description should survive verbatim, got {auth_err}", + ); + } + + /// Only 402 means "usage limit". Other failures must keep their existing + /// classification, or this becomes a catch-all that hides real errors. + #[tokio::test] + async fn non_402_failures_are_unchanged() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "boom"})); + }); + let server = start_server(mocks).await; + let strategy = make_access_key_strategy(&server); + + let err = strategy.get_token().await.expect_err("500 must fail"); + + let auth_err = match err { + AutoRefreshError::Auth(e) => e, + other => panic!("expected an auth error, got {other:?}"), + }; + assert_eq!(auth_err.error_code(), "SERVER_ERROR"); + } + + #[tokio::test] + async fn test_caches_token_after_initial_auth() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("new-token", 3600)); + }); + let server = start_server(mocks).await; + let strategy = make_access_key_strategy(&server); + + let token1 = strategy.get_token().await.unwrap(); + assert_eq!(token1.as_str(), "new-token"); + + // Replace mock — second call should use cached token. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "should not be called"})); + }); + + let token2 = strategy.get_token().await.unwrap(); + assert_eq!(token2.as_str(), "new-token"); + } + + // ---- TokenStore integration tests ---- + + #[tokio::test] + async fn test_loads_token_from_store_on_cold_start_no_http() { + // Mock returns 500 so we know the test fails loudly if the strategy + // ever calls authorise — but we expect it not to, since the store + // already holds a fresh token. + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "should not be called"})); + }); + let server = start_server(mocks).await; + + let store = Arc::new(crate::InMemoryTokenStore::new()); + store.save(&make_fresh_token("from-store")).await; + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); + let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); + + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "from-store", + "cold-start should return the token loaded from the store, not call HTTP" + ); + } + + #[tokio::test] + async fn test_persists_token_to_store_after_initial_auth() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("freshly-minted", 3600)); + }); + let server = start_server(mocks).await; + + let store = Arc::new(crate::InMemoryTokenStore::new()); + assert!( + store.load().await.is_none(), + "store should be empty before initial auth" + ); + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); + let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); + + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "freshly-minted", + "initial auth should return the newly issued token" + ); + + // After initial auth, the store should hold the new token. + let saved = store + .load() + .await + .expect("store should hold a token after initial auth"); + assert_eq!( + saved.access_token().as_str(), + "freshly-minted", + "store should hold the same token initial auth returned" + ); + } + + #[tokio::test] + async fn test_two_strategies_sharing_store_skip_http_on_second_cold_start() { + // Allow exactly one /api/authorise call; the second strategy must hit + // the store, not the server. + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("shared-cache-token", 3600)); + }); + let server = start_server(mocks).await; + let store = Arc::new(crate::InMemoryTokenStore::new()); + + // First strategy — does the HTTP exchange and writes to the store. + let refresher_a = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); + let strategy_a = AutoRefresh::with_store(refresher_a, Arc::clone(&store)); + let token_a = strategy_a.get_token().await.unwrap(); + assert_eq!( + token_a.as_str(), + "shared-cache-token", + "first strategy should mint a fresh token via HTTP" + ); + + // Replace the mock so any second call fails the test loudly. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "second strategy must hit store"})); + }); + + // Second strategy — fresh instance, same store. Should load from store. + let refresher_b = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); + let strategy_b = AutoRefresh::with_store(refresher_b, Arc::clone(&store)); + let token_b = strategy_b.get_token().await.unwrap(); + assert_eq!( + token_b.as_str(), + "shared-cache-token", + "second strategy should return the same token via the shared store, not the failing mock" + ); + } + + #[tokio::test] + async fn test_refreshes_when_store_has_expired_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("refreshed-after-store-miss", 3600)); + }); + let server = start_server(mocks).await; + + let store = Arc::new(crate::InMemoryTokenStore::new()); + store.save(&make_expired_token("stale-from-store")).await; + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); + let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); + + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "refreshed-after-store-miss", + "expired store entry should trigger refresh, not be returned as-is" + ); + + // Store should now hold the refreshed token, not the stale one. + let saved = store + .load() + .await + .expect("store should still hold a token after refresh"); + assert_eq!( + saved.access_token().as_str(), + "refreshed-after-store-miss", + "store should be overwritten with the refreshed token" + ); + } + + // ---- Refresh on expiry tests ---- + + #[tokio::test] + async fn test_re_authenticates_on_expiry() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("refreshed-token", 3600)); + }); + let server = start_server(mocks).await; + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); + let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); + + let token = strategy.get_token().await.unwrap(); + + assert_eq!(token.as_str(), "refreshed-token"); + } + + // ---- Error handling tests ---- + + #[tokio::test] + async fn test_initial_auth_failure() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.unauthorized() + .json(serde_json::json!({"error": "invalid key"})); + }); + let server = start_server(mocks).await; + let strategy = make_access_key_strategy(&server); + + let err = strategy.get_token().await.unwrap_err(); + + assert!(matches!(err, AutoRefreshError::Auth(_))); + } + + #[tokio::test] + async fn refresh_failure_propagates_the_refusal_not_expired() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.unauthorized() + .json(serde_json::json!({"error": "invalid key"})); + }); + let server = start_server(mocks).await; + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); + let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); + + let err = strategy.get_token().await.unwrap_err(); + + assert!( + matches!(err, AutoRefreshError::Auth(_)), + "the caller must see why the refresh was refused; flattening to \ + Expired tells them to do the one thing that cannot help — {err:?}", + ); + } + + #[tokio::test] + async fn usage_limit_on_refresh_reaches_the_caller() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.status(reqwest::StatusCode::PAYMENT_REQUIRED) + .json(serde_json::json!({ + "error": "access_denied", + "cs_code": "USAGE_LIMIT_EXCEEDED", + "error_description": "Workspace has exceeded its usage limit", + })); + }); + let server = start_server(mocks).await; + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); + let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); + + let AutoRefreshError::Auth(err) = strategy.get_token().await.unwrap_err() else { + panic!("expected a typed auth error"); + }; + + assert_eq!(err.error_code(), crate::error::codes::USAGE_LIMIT_EXCEEDED); + } + + /// Counts requests to `/api/authorise` and replies with a fixed status and + /// body, so a test can assert how many times the client actually went to + /// the network rather than only what it returned. + async fn start_counting_server( + status: axum::http::StatusCode, + body: serde_json::Value, + ) -> (Url, Arc<AtomicUsize>) { + type CountingState = (Arc<AtomicUsize>, axum::http::StatusCode, serde_json::Value); + + async fn handler( + axum::extract::State((calls, status, body)): axum::extract::State<CountingState>, + ) -> (axum::http::StatusCode, axum::Json<serde_json::Value>) { + calls.fetch_add(1, Ordering::SeqCst); + (status, axum::Json(body)) + } + + let calls = Arc::new(AtomicUsize::new(0)); + let app = axum::Router::new() + .route("/api/authorise", axum::routing::post(handler)) + .with_state((calls.clone(), status, body)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + (Url::parse(&format!("http://{addr}")).unwrap(), calls) + } + + /// A usage limit will not clear by asking again. Without a negative cache + /// an over-limit client re-POSTs `/api/authorise` on every `get_token` — + /// at its own request rate, against a decision already made. + #[tokio::test] + async fn a_settled_refusal_is_not_re_issued_on_every_call() { + let (url, calls) = start_counting_server( + axum::http::StatusCode::PAYMENT_REQUIRED, + serde_json::json!({ + "error": "access_denied", + "cs_code": "USAGE_LIMIT_EXCEEDED", + "error_description": "Workspace has exceeded its usage limit", + }), + ) + .await; + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + url, + None, + default_transport(), + ); + let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); + + for call in 1..=5 { + let AutoRefreshError::Auth(err) = strategy.get_token().await.unwrap_err() else { + panic!("call {call}: expected a typed auth error"); + }; + assert_eq!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "call {call}: the cached refusal must be replayed verbatim", + ); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "five get_token calls against a settled refusal must produce one \ + HTTP request, not five", + ); + } + + /// Serves a usage limit until `upgraded` is set, then a valid token — + /// modelling a customer upgrading their plan while a strategy is live. + async fn start_upgradable_server() -> (Url, Arc<AtomicUsize>, Arc<AtomicBool>) { + type UpgradableState = (Arc<AtomicUsize>, Arc<AtomicBool>); + + async fn handler( + axum::extract::State((calls, upgraded)): axum::extract::State<UpgradableState>, + ) -> (axum::http::StatusCode, axum::Json<serde_json::Value>) { + calls.fetch_add(1, Ordering::SeqCst); + if upgraded.load(Ordering::SeqCst) { + ( + axum::http::StatusCode::OK, + axum::Json(auth_response_json("upgraded-token", 3600)), + ) + } else { + ( + axum::http::StatusCode::PAYMENT_REQUIRED, + axum::Json(serde_json::json!({ + "error": "access_denied", + "cs_code": "USAGE_LIMIT_EXCEEDED", + "error_description": "Workspace has exceeded its usage limit", + })), + ) + } + } + + let calls = Arc::new(AtomicUsize::new(0)); + let upgraded = Arc::new(AtomicBool::new(false)); + let app = axum::Router::new() + .route("/api/authorise", axum::routing::post(handler)) + .with_state((calls.clone(), upgraded.clone())); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + ( + Url::parse(&format!("http://{addr}")).unwrap(), + calls, + upgraded, + ) + } + + fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + } + + /// A token whose expiry is an absolute instant, for tests driving a frozen + /// [`TestClock`](crate::clock::TestClock). + /// + /// `make_expired_token` reads the wall clock itself, so pairing it with a + /// clock frozen at a separately-read `now` is a race: if the two reads + /// straddle a second boundary the token is a second short of expired, no + /// refresh is attempted, and the test fails only on an unlucky run. Derive + /// both from one instant instead. + fn make_token_expiring_at(access: &str, expires_at: u64) -> Token { + Token { + access_token: SecretToken::new(access), + token_type: "Bearer".to_string(), + expires_at, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + } + + /// A cached refusal must not be permanent. Suppressing the retry storm is + /// the point; suppressing it forever means a customer who upgrades their + /// plan stays locked out until the process restarts. + #[tokio::test] + async fn a_settled_refusal_is_retried_once_it_expires() { + let (url, calls, _upgraded) = start_upgradable_server().await; + let start = now_secs(); + let clock = crate::clock::TestClock::new(start); + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + url, + None, + default_transport(), + ); + let strategy = AutoRefresh::with_token_and_clock( + refresher, + make_token_expiring_at("old-token", start - 3600), + clock.shared(), + ); + + strategy.get_token().await.unwrap_err(); + strategy.get_token().await.unwrap_err(); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "within the window the cached refusal is replayed", + ); + + clock.advance(super::super::auto_refresh::DENIAL_TTL_SECS + 1); + strategy.get_token().await.unwrap_err(); + + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "once the refusal expires the server must be asked again", + ); + } + + /// The reason the expiry matters: the upgrade has to become visible. + #[tokio::test] + async fn an_upgraded_plan_is_observed_once_the_refusal_expires() { + let (url, _calls, upgraded) = start_upgradable_server().await; + let start = now_secs(); + let clock = crate::clock::TestClock::new(start); + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + url, + None, + default_transport(), + ); + let strategy = AutoRefresh::with_token_and_clock( + refresher, + make_token_expiring_at("old-token", start - 3600), + clock.shared(), + ); + + strategy.get_token().await.unwrap_err(); + + // Customer upgrades their plan. + upgraded.store(true, Ordering::SeqCst); + + strategy + .get_token() + .await + .expect_err("still inside the refusal window"); + + clock.advance(super::super::auto_refresh::DENIAL_TTL_SECS + 1); + + let token = strategy + .get_token() + .await + .expect("an upgraded plan must eventually be observed"); + assert_eq!(token.as_str(), "upgraded-token"); + } + + /// A successful refresh clears the refusal outright, so the *next* call + /// after recovery does not wait out a stale window. + #[tokio::test] + async fn a_success_clears_the_refusal_immediately() { + let (url, calls, upgraded) = start_upgradable_server().await; + let start = now_secs(); + let clock = crate::clock::TestClock::new(start); + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + url, + None, + default_transport(), + ); + let strategy = AutoRefresh::with_token_and_clock( + refresher, + make_token_expiring_at("old-token", start - 3600), + clock.shared(), + ); + + strategy.get_token().await.unwrap_err(); + upgraded.store(true, Ordering::SeqCst); + clock.advance(super::super::auto_refresh::DENIAL_TTL_SECS + 1); + strategy.get_token().await.unwrap(); + + let before = calls.load(Ordering::SeqCst); + strategy + .get_token() + .await + .expect("cached token is still valid"); + + assert_eq!( + calls.load(Ordering::SeqCst), + before, + "a valid cached token needs no further round-trip", + ); + } + + /// A wall clock can move backwards — NTP step, VM snapshot restore, a + /// manual change. `now - recorded_at` would then underflow, and with a + /// wrapping subtraction the refusal would look freshly recorded for + /// billions of seconds. Erring towards asking again costs one request. + #[tokio::test] + async fn a_backwards_clock_does_not_pin_the_refusal() { + let (url, calls, upgraded) = start_upgradable_server().await; + let start = now_secs(); + let clock = crate::clock::TestClock::new(start); + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + url, + None, + default_transport(), + ); + // Expired well before `start`, so it is still expired after the rewind + // — otherwise the token-expiry check short-circuits and the refusal is + // never consulted, and the test would prove nothing. + let strategy = AutoRefresh::with_token_and_clock( + refresher, + make_token_expiring_at("old-token", start - 86_400), + clock.shared(), + ); + + strategy.get_token().await.unwrap_err(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + + upgraded.store(true, Ordering::SeqCst); + clock.set(start - 3600); + + strategy + .get_token() + .await + .expect("a clock that jumped backwards must not pin the refusal"); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + /// The mirror of the above: a server fault may clear, so it must *not* + /// stick. Treating a transient failure as permanent locks a client out of + /// a service that has since recovered — the worse of the two mistakes. + #[tokio::test] + async fn a_server_fault_is_retried_on_the_next_call() { + let (url, calls) = start_counting_server( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + serde_json::json!({}), + ) + .await; + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + url, + None, + default_transport(), + ); + let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); + + for _ in 0..3 { + strategy.get_token().await.unwrap_err(); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 3, + "a server fault must be retried; only settled refusals stick", + ); + } + + // ---- Cascade prevention tests ---- + + #[tokio::test] + async fn test_concurrent_initial_auth_only_one_http_call() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("new-token", 3600)); + }); + let server = start_server(mocks).await; + let strategy = Arc::new(make_access_key_strategy(&server)); + + let s1 = Arc::clone(&strategy); + let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); + + let s2 = Arc::clone(&strategy); + let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); + + let (result_a, result_b) = tokio::join!(handle_a, handle_b); + let token_a = result_a.unwrap(); + let token_b = result_b.unwrap(); + + assert_eq!(token_a.as_str(), "new-token"); + assert_eq!(token_b.as_str(), "new-token"); + } + + #[tokio::test] + async fn test_concurrent_access_expired_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("refreshed-token", 3600)); + }); + let server = start_server(mocks).await; + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); + let strategy = Arc::new(AutoRefresh::with_token( + refresher, + make_expired_token("old-token"), + )); + + let s1 = Arc::clone(&strategy); + let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); + + let s2 = Arc::clone(&strategy); + let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); + + let (result_a, result_b) = tokio::join!(handle_a, handle_b); + let token_a = result_a.unwrap(); + let token_b = result_b.unwrap(); + + assert_eq!(token_a.as_str(), "refreshed-token"); + assert_eq!(token_b.as_str(), "refreshed-token"); + } + + // ---- Concurrent access: expiring but usable ---- + + #[tokio::test] + async fn test_concurrent_access_expiring_but_usable() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("refreshed-token", 3600)); + }); + let server = start_server(mocks).await; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let expiring_token = Token { + access_token: SecretToken::new("still-usable"), + token_type: "Bearer".to_string(), + expires_at: now + 30, // is_expired() = true (within 90s), is_usable() = true + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + }; + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); + let strategy = Arc::new(AutoRefresh::with_token(refresher, expiring_token)); + + let s1 = Arc::clone(&strategy); + let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); + + let s2 = Arc::clone(&strategy); + let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); + + let (result_a, result_b) = tokio::join!(handle_a, handle_b); + let token_a = result_a.unwrap(); + let token_b = result_b.unwrap(); + + // Both should succeed with either old or refreshed token. + assert!( + token_a.as_str() == "still-usable" || token_a.as_str() == "refreshed-token", + "unexpected token_a: {}", + token_a.as_str() + ); + assert!( + token_b.as_str() == "still-usable" || token_b.as_str() == "refreshed-token", + "unexpected token_b: {}", + token_b.as_str() + ); + } + + // ---- Stress tests ---- + + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + + #[derive(Clone)] + struct CountingState { + total: Arc<AtomicUsize>, + current: Arc<AtomicUsize>, + peak: Arc<AtomicUsize>, + } + + impl CountingState { + fn new() -> Self { + Self { + total: Arc::new(AtomicUsize::new(0)), + current: Arc::new(AtomicUsize::new(0)), + peak: Arc::new(AtomicUsize::new(0)), + } + } + + fn enter(&self) { + self.total.fetch_add(1, Ordering::SeqCst); + let prev = self.current.fetch_add(1, Ordering::SeqCst); + self.peak.fetch_max(prev + 1, Ordering::SeqCst); + } + + fn exit(&self) { + self.current.fetch_sub(1, Ordering::SeqCst); + } + + fn peak(&self) -> usize { + self.peak.load(Ordering::SeqCst) + } + + fn total(&self) -> usize { + self.total.load(Ordering::SeqCst) + } + } + + #[derive(Clone)] + struct DelayedAuthState { + counting: CountingState, + delay: Duration, + } + + async fn delayed_auth_handler( + axum::extract::State(state): axum::extract::State<DelayedAuthState>, + ) -> axum::Json<serde_json::Value> { + state.counting.enter(); + tokio::time::sleep(state.delay).await; + state.counting.exit(); + // CTS returns `expiry` as an absolute epoch (JWT `exp`); model a token + // valid for 1 hour from now. + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + axum::Json(serde_json::json!({ + "accessToken": "refreshed-token", + "expiry": now + 3600 + })) + } + + async fn start_axum_server(state: DelayedAuthState) -> (Url, CountingState) { + let counting = state.counting.clone(); + let app = axum::Router::new() + .route("/api/authorise", axum::routing::post(delayed_auth_handler)) + .with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let base_url = Url::parse(&format!("http://{addr}")).unwrap(); + (base_url, counting) + } + + const CONCURRENCY: usize = 50; + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_stress_initial_auth() { + let state = DelayedAuthState { + counting: CountingState::new(), + delay: Duration::from_millis(200), + }; + let (base_url, stats) = start_axum_server(state).await; + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + base_url, + None, + default_transport(), + ); + let strategy = Arc::new(AutoRefresh::with_store(refresher, crate::NoStore)); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + for token in &results { + assert_eq!(token.as_str(), "refreshed-token"); + } + + assert!( + elapsed < Duration::from_millis(600), + "expected < 600ms, got {:?}", + elapsed + ); + assert_eq!(stats.total(), 1, "only one auth request should be made"); + assert_eq!(stats.peak(), 1, "peak concurrency to auth endpoint"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_stress_cached_token() { + let state = DelayedAuthState { + counting: CountingState::new(), + delay: Duration::from_millis(500), + }; + let (base_url, stats) = start_axum_server(state).await; + + // Pre-authenticate. + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + base_url, + None, + default_transport(), + ); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let token = Token { + access_token: SecretToken::new("cached-token"), + token_type: "Bearer".to_string(), + expires_at: now + 3600, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + }; + let strategy = Arc::new(AutoRefresh::with_token(refresher, token)); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + for token in &results { + assert_eq!(token.as_str(), "cached-token"); + } + + assert!( + elapsed < Duration::from_millis(200), + "expected < 200ms for cached tokens, got {:?}", + elapsed + ); + assert_eq!(stats.total(), 0, "no auth requests should be made"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_stress_expiring_but_usable_non_blocking() { + let state = DelayedAuthState { + counting: CountingState::new(), + delay: Duration::from_millis(500), + }; + let (base_url, stats) = start_axum_server(state).await; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let expiring_token = Token { + access_token: SecretToken::new("still-usable"), + token_type: "Bearer".to_string(), + expires_at: now + 30, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + }; + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + base_url, + None, + default_transport(), + ); + let strategy = Arc::new(AutoRefresh::with_token(refresher, expiring_token)); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { + let call_start = Instant::now(); + let token = s.get_token().await.unwrap(); + (token, call_start.elapsed()) + })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let _elapsed = start.elapsed(); + + for (token, _) in &results { + assert!( + token.as_str() == "still-usable" || token.as_str() == "refreshed-token", + "unexpected token: {}", + token.as_str() + ); + } + + // At least N-1 callers should be fast (non-blocking). + let fast_callers = results + .iter() + .filter(|(_, dur)| *dur < Duration::from_millis(100)) + .count(); + assert!( + fast_callers >= CONCURRENCY - 1, + "expected at least {} fast callers, got {}", + CONCURRENCY - 1, + fast_callers, + ); + + assert_eq!(stats.peak(), 1, "peak concurrency to auth endpoint"); + assert_eq!(stats.total(), 1, "total auth requests"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_stress_expired_token_blocks() { + let refresh_delay = Duration::from_millis(200); + let state = DelayedAuthState { + counting: CountingState::new(), + delay: refresh_delay, + }; + let (base_url, stats) = start_axum_server(state).await; + + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + base_url, + None, + default_transport(), + ); + let strategy = Arc::new(AutoRefresh::with_token( + refresher, + make_expired_token("old-token"), + )); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + for token in &results { + assert_eq!(token.as_str(), "refreshed-token"); + } + + assert!( + elapsed < refresh_delay + Duration::from_millis(200), + "expected < {:?}, got {:?}", + refresh_delay + Duration::from_millis(200), + elapsed + ); + + assert_eq!(stats.peak(), 1, "peak concurrency to auth endpoint"); + assert_eq!(stats.total(), 1, "total auth requests"); + } +} diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs new file mode 100644 index 000000000..7209024c8 --- /dev/null +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -0,0 +1,417 @@ +use cts_common::{Crn, CtsServiceDiscovery, ServiceDiscovery, WorkspaceId}; + +use crate::access_key::AccessKey; +use crate::access_key_refresher::AccessKeyRefresher; +use crate::auto_refresh::AutoRefresh; +use crate::token_store::{NoStore, TokenStore}; +use crate::transport::{self, SharedTransport}; +use crate::HttpTransport; +use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, ServiceToken}; + +/// An [`AuthStrategy`] that uses a static access key to authenticate against +/// a specific workspace. +/// +/// The strategy is bound to a workspace CRN at construction. The region is +/// derived from the CRN — there is no separate `region` argument — so a +/// caller can't accidentally point the strategy at one region while the +/// CRN says another. +/// +/// The first call to [`get_token`](AuthStrategy::get_token) authenticates +/// with the server. Subsequent calls return the cached token until it +/// expires, at which point re-authentication happens automatically. Every +/// returned token is checked against the CRN; post-auth verification can +/// fail in two ways: +/// +/// - [`AuthError::WorkspaceMismatch`] — the JWT decoded cleanly but its +/// `workspace` claim doesn't match the CRN's workspace ID. +/// - [`AuthError::InvalidToken`] — the JWT is malformed or missing the +/// `workspace` claim entirely, so verification can't run. +/// +/// Either outcome is preferred over silently letting the caller operate on +/// a different workspace than they specified. +/// +/// When constructed via [`AccessKeyStrategyBuilder::with_token_store`], the +/// strategy also persists tokens through an external [`TokenStore`] so that +/// short-lived strategy instances (e.g. one per Edge Function request) can +/// share a cache and avoid re-authenticating every cold start. +/// +/// # Example +/// +/// ```no_run +/// use stack_auth::{AccessKey, AccessKeyStrategy}; +/// use cts_common::Crn; +/// +/// let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); +/// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); +/// let strategy = AccessKeyStrategy::new(crn, key).unwrap(); +/// ``` +pub struct AccessKeyStrategy<S = NoStore> { + inner: AutoRefresh<AccessKeyRefresher, S>, + expected_workspace: WorkspaceId, +} + +impl AccessKeyStrategy { + /// Create a new `AccessKeyStrategy` for the given workspace CRN and + /// access key. The auth endpoint is resolved automatically via service + /// discovery using the region encoded in the CRN. + /// + /// The `CS_CTS_HOST` environment variable, if set and non-empty, + /// overrides service discovery — useful for pointing the strategy at + /// a staging CTS or a local mock without changing the CRN. + /// + /// A CRN with a `service_name` component (e.g. + /// `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY:zerokms`) is accepted; the + /// `service_name` is ignored. Only the region and workspace ID are + /// load-bearing for this strategy. + pub fn new(workspace_crn: Crn, access_key: AccessKey) -> Result<Self, AuthError> { + Self::builder(workspace_crn, access_key).build() + } + + /// Return a builder for configuring an `AccessKeyStrategy` before construction. + /// + /// # Example + /// + /// ```no_run + /// use stack_auth::{AccessKey, AccessKeyStrategy}; + /// use cts_common::Crn; + /// + /// let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); + /// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); + /// let strategy = AccessKeyStrategy::builder(crn, key) + /// .audience("my-audience") + /// .build() + /// .unwrap(); + /// ``` + pub fn builder(workspace_crn: Crn, access_key: AccessKey) -> AccessKeyStrategyBuilder { + AccessKeyStrategyBuilder { + workspace_crn, + access_key: access_key.into_secret_token(), + audience: None, + base_url_override: None, + token_store: NoStore, + transport: None, + } + } +} + +impl<S: TokenStore> AuthStrategy for &AccessKeyStrategy<S> { + async fn get_token(self) -> Result<ServiceToken, AuthError> { + self.inner + .get_token() + .await? + .verify_workspace(self.expected_workspace) + } +} + +/// Builder for [`AccessKeyStrategy`]. +/// +/// Created via [`AccessKeyStrategy::builder`]. +pub struct AccessKeyStrategyBuilder<S = NoStore> { + workspace_crn: Crn, + access_key: SecretToken, + audience: Option<String>, + base_url_override: Option<url::Url>, + token_store: S, + transport: Option<SharedTransport>, +} + +impl<S> AccessKeyStrategyBuilder<S> { + /// Send this strategy's requests through `transport` instead of the + /// bundled `reqwest` client. + /// + /// Without the `http` feature there is no bundled client, so this is + /// required; with it, this is how a host with its own HTTP stack (or a + /// test with a stub) takes over the wire without changing anything else + /// about the strategy. + pub fn transport(mut self, transport: impl HttpTransport) -> Self { + self.transport = Some(transport::share(transport)); + self + } + + /// [`transport`](Self::transport), for a caller that may or may not + /// have one — the auto strategy hands its own through. + pub(crate) fn maybe_transport(mut self, transport: Option<SharedTransport>) -> Self { + self.transport = transport; + self + } + /// Set the audience for token requests. + pub fn audience(mut self, audience: impl Into<String>) -> Self { + self.audience = Some(audience.into()); + self + } + + /// Override the CTS base URL resolved for this strategy. + /// + /// Takes precedence over both the `CS_CTS_HOST` environment variable and + /// region-derived service discovery. Use it to point a single strategy + /// instance at a specific CTS host — e.g. a self-hosted CTS, or a local + /// mock auth server in development — without relying on the process-wide + /// `CS_CTS_HOST`, which would redirect every other CTS client sharing the + /// process. + pub fn base_url(mut self, url: url::Url) -> Self { + self.base_url_override = Some(url); + self + } + + /// Wire an external [`TokenStore`] into the strategy. + /// + /// On every call to [`get_token`](AuthStrategy::get_token), if no token is + /// cached in memory, the store is consulted before falling back to + /// re-authenticating with the access key. After every successful refresh + /// or initial auth, the new token is written back to the store. Use this + /// from short-lived strategy instances (Edge Functions, Workers, proxy + /// worker pools) to share a service-token cache across processes. + /// + /// Returns a new builder with the store type erased into the chain — see + /// [`InMemoryTokenStore`](crate::InMemoryTokenStore) and + /// [`TokenStoreFn`](crate::TokenStoreFn) for ready-made + /// implementations. + pub fn with_token_store<T: TokenStore>(self, store: T) -> AccessKeyStrategyBuilder<T> { + AccessKeyStrategyBuilder { + workspace_crn: self.workspace_crn, + access_key: self.access_key, + audience: self.audience, + base_url_override: self.base_url_override, + token_store: store, + transport: self.transport, + } + } +} + +impl<S: TokenStore> AccessKeyStrategyBuilder<S> { + /// Build the [`AccessKeyStrategy`]. + /// + /// Resolves the base URL in priority order: an explicit [`base_url`] + /// override, then the `CS_CTS_HOST` environment variable, then service + /// discovery using the CRN's region. + /// + /// [`base_url`]: Self::base_url + pub fn build(self) -> Result<AccessKeyStrategy<S>, AuthError> { + let expected_workspace = self.workspace_crn.workspace_id; + let region = self.workspace_crn.region; + let base_url = match self.base_url_override { + Some(url) => url, + None => { + crate::cts_base_url_from_env()?.unwrap_or(CtsServiceDiscovery::endpoint(region)?) + } + }; + let refresher = AccessKeyRefresher::new( + self.access_key, + ensure_trailing_slash(base_url), + self.audience, + transport::resolve(self.transport)?, + ); + Ok(AccessKeyStrategy { + inner: AutoRefresh::with_store(refresher, self.token_store), + expected_workspace, + }) + } +} + +#[cfg(test)] +#[cfg(feature = "http")] +mod workspace_verification_tests { + use super::*; + use crate::test_support::{crn_with_workspace, jwt_with_workspace}; + use mocktail::prelude::*; + use std::time::UNIX_EPOCH; + + async fn start_mock_server_returning_jwt(workspace: &str) -> MockServer { + let mut mocks = MockSet::new(); + let jwt = jwt_with_workspace(workspace); + mocks.mock(move |when, then| { + when.post().path("/api/authorise"); + then.json(serde_json::json!({ + "accessToken": jwt, + "expiry": 3600, + })); + }); + let server = MockServer::new_http("access-key-strategy-workspace-test").with_mocks(mocks); + #[allow(clippy::expect_used)] + server.start().await.expect("mock server start"); + server + } + + fn test_access_key() -> AccessKey { + "CSAKtestKeyId.testKeySecret" + .parse() + .expect("test access key parses") + } + + /// Happy path — JWT workspace matches the CRN: `get_token()` returns + /// the token cleanly. + #[tokio::test] + async fn returns_token_when_workspace_matches() { + const WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(WS).await; + let crn = crn_with_workspace(WS); + + let strategy = AccessKeyStrategy::builder(crn, test_access_key()) + .base_url(server.url("")) + .build() + .expect("builder"); + + let token = (&strategy).get_token().await.expect("get_token"); + assert_eq!( + token.workspace_id().expect("workspace_id").as_str(), + WS, + "happy-path token should carry the expected workspace", + ); + } + + /// Mismatch — JWT workspace differs from the CRN's: `get_token()` + /// returns `AuthError::WorkspaceMismatch` rather than the token. + #[tokio::test] + async fn errors_when_token_workspace_differs_from_crn() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const CRN_WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(TOKEN_WS).await; + let crn = crn_with_workspace(CRN_WS); + + let strategy = AccessKeyStrategy::builder(crn, test_access_key()) + .base_url(server.url("")) + .build() + .expect("builder"); + + let err = (&strategy) + .get_token() + .await + .expect_err("expected mismatch"); + match err { + AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch { + expected_workspace, + token_workspace, + }) => { + assert_eq!(expected_workspace.as_str(), CRN_WS); + assert_eq!(token_workspace.as_str(), TOKEN_WS); + } + other => panic!("expected WorkspaceMismatch, got {other:?}"), + } + assert_eq!( + AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch { + expected_workspace: CRN_WS.parse().unwrap(), + token_workspace: TOKEN_WS.parse().unwrap(), + }) + .error_code(), + "WORKSPACE_MISMATCH", + ); + } + + /// A CRN carrying a `service_name` component is accepted; the + /// `service_name` is ignored. The strategy uses only the region (for + /// service discovery) and the workspace ID (for token verification). + /// Pinned as a test rather than left to implementation drift so that a + /// future contributor doesn't tighten the constructor into rejecting + /// these CRNs without realising the docstring already promises + /// acceptance. + #[tokio::test] + async fn accepts_crn_with_service_name() { + const WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(WS).await; + let crn: Crn = format!("crn:ap-southeast-2.aws:{WS}:zerokms") + .parse() + .expect("CRN with service_name parses"); + + let strategy = AccessKeyStrategy::builder(crn, test_access_key()) + .base_url(server.url("")) + .build() + .expect("CRN with service_name should construct a strategy"); + + let token = (&strategy).get_token().await.expect("get_token"); + assert_eq!( + token.workspace_id().expect("workspace_id").as_str(), + WS, + "service_name is ignored — verification still uses the workspace ID", + ); + } + + /// A pre-populated [`TokenStore`] returning a token for a *different* + /// workspace must still be rejected by the strategy's wrapper. This + /// is the cross-feature interaction the CRN parity work is designed + /// to protect — a shared cookie / KV cache between strategies bound + /// to different workspaces must never let a load from the store + /// bypass workspace verification. + /// + /// Drives the assertion without any HTTP traffic: a 500-returning + /// mock fails the test loudly if the strategy ever reaches the + /// authorise endpoint instead of trusting the store. + #[tokio::test] + async fn rejects_stored_token_for_different_workspace() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const CRN_WS: &str = "ZVATKW3VHMFG27DY"; + + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "store must satisfy the request"})); + }); + let server = + MockServer::new_http("access-key-strategy-store-mismatch-test").with_mocks(mocks); + #[allow(clippy::expect_used)] + server.start().await.expect("mock server start"); + + let now = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_secs(); + let stored = crate::Token { + access_token: crate::SecretToken::new(jwt_with_workspace(TOKEN_WS)), + token_type: "Bearer".to_string(), + expires_at: now + 3600, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + }; + let store = std::sync::Arc::new(crate::InMemoryTokenStore::new()); + store.save(&stored).await; + + let strategy = AccessKeyStrategy::builder(crn_with_workspace(CRN_WS), test_access_key()) + .base_url(server.url("")) + .with_token_store(std::sync::Arc::clone(&store)) + .build() + .expect("builder"); + + let err = (&strategy) + .get_token() + .await + .expect_err("expected mismatch from stored token"); + assert!( + matches!(err, AuthError::WorkspaceMismatch { .. }), + "expected WorkspaceMismatch, got {err:?}", + ); + } + + /// Regression guard — the workspace check runs on *every* `get_token()` + /// call, not only on the call that triggers initial authentication. + /// A future optimisation that cached the "verified" result, or that + /// stashed the token into a field bypassing the wrapper, would let a + /// mismatched token slide through on the second call. Verified by + /// calling `get_token()` twice against the same mock and asserting + /// both fail with `WorkspaceMismatch`. + #[tokio::test] + async fn errors_on_each_subsequent_get_token_call() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const CRN_WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(TOKEN_WS).await; + let crn = crn_with_workspace(CRN_WS); + + let strategy = AccessKeyStrategy::builder(crn, test_access_key()) + .base_url(server.url("")) + .build() + .expect("builder"); + + for call in 1..=2 { + let result = (&strategy).get_token().await; + let err = match result { + Ok(_) => panic!("call {call}: expected Err, got Ok"), + Err(e) => e, + }; + assert!( + matches!(err, AuthError::WorkspaceMismatch { .. }), + "call {call}: expected WorkspaceMismatch, got {err:?}", + ); + } + } +} diff --git a/packages/stack-auth/src/auth_strategy_fn.rs b/packages/stack-auth/src/auth_strategy_fn.rs new file mode 100644 index 000000000..3cf9c9e9e --- /dev/null +++ b/packages/stack-auth/src/auth_strategy_fn.rs @@ -0,0 +1,139 @@ +//! [`AuthStrategy`] adapter built from an async closure. +//! +//! `AuthStrategyFn` is the closure-shaped impl of the *acquisition layer* +//! ([`AuthStrategy`]): the closure runs every time a token is requested and +//! returns a [`ServiceToken`]. Use this when the actual token acquisition +//! lives outside `stack-auth` — most commonly behind an FFI callback +//! (a JS `getToken()` reached via Neon, a foreign IPC channel, a hand-rolled +//! test double). +//! +//! Sibling primitive on the *persistence layer* is +//! [`TokenStoreFn`](crate::TokenStoreFn), which plugs into an existing +//! strategy to back its cache. `AuthStrategyFn` replaces the whole +//! acquisition pipeline; `TokenStoreFn` slots into one. See `auth-strategy-handover.md` +//! at the repo root for the wider design discussion. +//! +//! [`cipherstash-client`]: https://docs.rs/cipherstash-client/ + +use std::future::Future; + +use crate::{AuthError, AuthStrategy, ServiceToken}; + +/// [`AuthStrategy`] backed by a user-supplied async closure that returns +/// a [`ServiceToken`]. +/// +/// # Example +/// +/// ```no_run +/// use stack_auth::{AuthError, AuthStrategyFn, SecretToken, ServiceToken}; +/// +/// let strategy = AuthStrategyFn::new(|| async { +/// // Real consumers would call into FFI / IPC / a cached token store. +/// Ok::<_, AuthError>(ServiceToken::new(SecretToken::new("dummy.jwt.value".to_string()))) +/// }); +/// ``` +/// +/// # When to reach for this vs [`TokenStoreFn`](crate::TokenStoreFn) +/// +/// - **`AuthStrategyFn`**: you control the *entire* token pipeline — fetch, +/// refresh, cache. `cipherstash-client` calls your closure and uses +/// whatever it returns, no further questions asked. Used by FFI bindings +/// that proxy to a JS-side strategy doing all the work upstream. +/// - **`TokenStoreFn`**: you want stack-auth's `AccessKeyStrategy` (or +/// another concrete strategy) to do the HTTP/refresh work, and you just +/// want to plug in custom persistence (a cookie, a KV blob, Redis). +pub struct AuthStrategyFn<F> { + get_token: F, +} + +impl<F> AuthStrategyFn<F> { + /// Build an `AuthStrategyFn` from an async closure. The closure fires + /// every time [`AuthStrategy::get_token`] is called on a reference to + /// this strategy — typically once per `cipherstash-client` HTTP request, + /// modulo any in-process caching the closure does internally. + pub fn new(get_token: F) -> Self { + Self { get_token } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl<F, Fut> AuthStrategy for &AuthStrategyFn<F> +where + F: Fn() -> Fut + Send + Sync, + Fut: Future<Output = Result<ServiceToken, AuthError>> + Send, +{ + fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> + Send { + (self.get_token)() + } +} + +#[cfg(target_arch = "wasm32")] +impl<F, Fut> AuthStrategy for &AuthStrategyFn<F> +where + F: Fn() -> Fut, + Fut: Future<Output = Result<ServiceToken, AuthError>>, +{ + fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> { + (self.get_token)() + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use crate::SecretToken; + + use super::*; + + fn dummy_service_token(jwt: &str) -> ServiceToken { + ServiceToken::new(SecretToken::new(jwt.to_string())) + } + + #[tokio::test] + async fn closure_runs_on_each_get_token_call() { + let calls = Arc::new(AtomicUsize::new(0)); + let calls_clone = Arc::clone(&calls); + let strategy = AuthStrategyFn::new(move || { + let calls = Arc::clone(&calls_clone); + async move { + let n = calls.fetch_add(1, Ordering::SeqCst); + Ok(dummy_service_token(&format!("jwt-{n}"))) + } + }); + + let first = (&strategy).get_token().await.unwrap(); + assert_eq!( + first.as_str(), + "jwt-0", + "first call should yield the first token the closure produced" + ); + + let second = (&strategy).get_token().await.unwrap(); + assert_eq!( + second.as_str(), + "jwt-1", + "second call should re-invoke the closure" + ); + + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "closure should have fired exactly twice" + ); + } + + #[tokio::test] + async fn closure_errors_propagate_unchanged() { + let strategy = AuthStrategyFn::new(|| async { + Err(AuthError::AccessDenied(crate::error::AccessDenied)) + }); + let err = (&strategy).get_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::AccessDenied(_)), + "AccessDenied from the closure should surface verbatim, got: {err:?}" + ); + } +} diff --git a/packages/stack-auth/src/authorize_dto.rs b/packages/stack-auth/src/authorize_dto.rs new file mode 100644 index 000000000..a0fb910a1 --- /dev/null +++ b/packages/stack-auth/src/authorize_dto.rs @@ -0,0 +1,77 @@ +//! Shared DTO for the CTS `POST /api/authorise` endpoint. +//! +//! Both [`AccessKeyRefresher`](crate::access_key_refresher::AccessKeyRefresher) +//! and [`OidcRefresher`](crate::oidc_refresher::OidcRefresher) exchange a +//! credential for a CTS +//! service token at the same endpoint; the success response is identical, so +//! the wire contract lives here in one place. The request bodies differ +//! (different credential fields) and stay private to each refresher. + +use crate::{SecretToken, Token}; + +/// Success response from `POST /api/authorise`. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AuthoriseResponse { + pub(crate) access_token: SecretToken, + pub(crate) expiry: u64, +} + +/// A `/api/authorise` success response *is* a complete CTS service token. Both +/// the access-key and OIDC federation flows hit the same endpoint and mint the +/// same kind of token, so the response → [`Token`] mapping lives here once +/// rather than being duplicated in each refresher — duplication is exactly how +/// the CIP-3233 expiry bug survived in the OIDC refresher after being fixed for +/// access keys. +impl From<AuthoriseResponse> for Token { + fn from(resp: AuthoriseResponse) -> Self { + Token { + access_token: resp.access_token, + token_type: "Bearer".to_string(), + // CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (it is + // the JWT `exp` claim), NOT a relative duration. Adding `now` here would + // push the local expiry decades into the future, so `AutoRefresh` would + // never consider the token expired and never refresh it — the token would + // then silently die at its real (~15 min) `exp` and every request would + // fail until the process restarted. Use the value as-is. See CIP-3233. + expires_at: resp.expiry, + // `/api/authorise` issues no refresh token and carries no region / client + // / device metadata; those are populated only by the OAuth device flow. + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The single source of truth for the response → `Token` mapping: CTS + /// `expiry` is an ABSOLUTE Unix epoch and must be used verbatim. Pre-fix the + /// refreshers did `now + expiry`; the regression in CIP-3233 (and its OIDC + /// sibling) now cannot recur in only one flow because there is only one flow. + #[test] + fn authorise_response_maps_expiry_as_absolute_epoch() { + let resp = AuthoriseResponse { + access_token: SecretToken::new("cts-token"), + expiry: 1_900_000_000, // an absolute Unix epoch, not a duration + }; + + let token = Token::from(resp); + + assert_eq!( + token.expires_at(), + 1_900_000_000, + "expiry must be carried through as-is, never offset by `now`" + ); + assert_eq!(token.token_type(), "Bearer"); + assert_eq!(token.access_token().as_str(), "cts-token"); + assert!( + token.refresh_token.is_none(), + "/api/authorise issues no refresh token" + ); + } +} diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs new file mode 100644 index 000000000..91a04a2d8 --- /dev/null +++ b/packages/stack-auth/src/auto_refresh.rs @@ -0,0 +1,2653 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use tokio::sync::{Mutex, MutexGuard, Notify}; + +use crate::clock::{system_clock, SharedClock}; +use crate::refresher::Refresher; +use crate::token_store::{NoStore, TokenStore}; +use crate::{ServiceToken, Token}; + +/// Internal errors from [`AutoRefresh::get_token`]. +/// +/// Strategy wrappers convert these into [`AuthError`](crate::AuthError) for the +/// public API. +#[derive(Debug, thiserror::Error)] +pub(crate) enum AutoRefreshError { + /// No token is cached and the strategy cannot self-authenticate. + #[error("No token found")] + NotFound, + /// The token has expired and refresh failed or is unavailable. + #[error("Token has expired")] + Expired, + /// The refresh/auth HTTP call failed. + #[error("Auth error: {0}")] + Auth(#[from] crate::AuthError), +} + +impl From<AutoRefreshError> for crate::AuthError { + fn from(err: AutoRefreshError) -> Self { + match err { + AutoRefreshError::NotFound => { + crate::AuthError::NotAuthenticated(crate::error::NotAuthenticated) + } + AutoRefreshError::Expired => crate::AuthError::TokenExpired(crate::error::TokenExpired), + AutoRefreshError::Auth(e) => e, + } + } +} + +/// Caches a token in memory and uses a [`Refresher`] to re-authenticate +/// or refresh before expiry, optionally backed by an external [`TokenStore`] +/// for persistence across short-lived strategy instances. +/// +/// See the [crate-level documentation](crate#token-refresh) for a full +/// description of the concurrency model and flow diagram. +pub(crate) struct AutoRefresh<R, S = NoStore> { + refresher: R, + state: Mutex<State>, + store: S, + /// Set to `true` while a refresh HTTP call is in-flight. + /// + /// Stored as an [`AtomicBool`] rather than inside [`State`] so that + /// [`CancelGuard`] can reset it on future cancellation without acquiring + /// the mutex. + refresh_in_progress: AtomicBool, + refresh_notify: Notify, + /// Source of "now" for token-expiry checks. [`SystemClock`](crate::clock::SystemClock) + /// in production; an injected clock in tests so expiry is deterministic. + clock: SharedClock, +} + +/// How long a cached refusal is replayed before the server is asked again. +/// +/// The cache exists to stop an over-limit client re-issuing the same doomed +/// request at its own request rate. It must not outlive its usefulness: the +/// customer can upgrade their plan at any moment, and that is invisible to us +/// until we ask. Sixty seconds turns a per-request storm into one call a +/// minute while bounding how long an upgrade goes unnoticed. +pub(crate) const DENIAL_TTL_SECS: u64 = 60; + +struct State { + token: Option<Token>, + /// The last refusal that will not resolve by retrying, if any. + /// + /// Without this, a client whose org is over its usage limit re-issues the + /// same doomed request on every `get_token` call — at request rate, against + /// a decision that has already been made. Expires after + /// [`DENIAL_TTL_SECS`], and is cleared outright by any successful refresh. + denial: Option<StickyDenial>, + /// The error from the most recently completed refresh attempt, if it + /// failed. Cleared on every successful refresh. + /// + /// Unlike `denial`, this is not TTL'd, is not restricted to account-level + /// refusals, and is never consulted by `get_token`'s own retry path — it + /// exists solely so a caller parked in + /// [`wait_for_in_flight_refresh`](AutoRefresh::wait_for_in_flight_refresh) + /// sees the *same* outcome as whoever actually performed the refresh it + /// was waiting on, instead of a generic `Expired` that discards why the + /// wait ended in failure. + last_refresh_error: Option<(&'static str, String)>, +} + +/// A non-retryable refusal, held in a form that can be handed to more than one +/// caller. +/// +/// [`AuthError`](crate::AuthError) is not `Clone` — it wraps foreign error +/// types — so the denial is stored as the wire pair it round-trips through and +/// rebuilt per call. `USAGE_LIMIT_EXCEEDED` round-trips exactly, message +/// included; see [`AuthError::from_error_code`](crate::AuthError::from_error_code). +struct StickyDenial { + code: &'static str, + message: String, + recorded_at: u64, +} + +impl StickyDenial { + fn new(err: &crate::AuthError, now: u64) -> Self { + Self { + code: err.error_code(), + message: err.to_string(), + recorded_at: now, + } + } + + /// Whether the refusal has outlived its window. + /// + /// A clock reading earlier than the moment of recording (NTP step, VM + /// snapshot restore, manual change) counts as stale. The elapsed time is + /// then unknowable, and the two ways of being wrong are not equal: asking + /// again costs one request, while pinning the entry locks the caller out + /// until the clock catches up — which for a large backwards step is + /// indistinguishable from forever. + fn is_stale(&self, now: u64) -> bool { + now < self.recorded_at || now - self.recorded_at >= DENIAL_TTL_SECS + } + + fn to_error(&self) -> crate::AuthError { + crate::AuthError::from_error_code(self.code, &self.message, &serde_json::Map::new()) + } +} + +/// Whether a failed [`Refresher::refresh`] spent its credential. +/// +/// A store error means the upstream exchange succeeded and only persisting +/// the result failed (see [`Refresher::refresh`]). The credential has been +/// used, so restoring it would replay it on the next call; for a rotating +/// refresh token that replay trips the issuer's reuse detection and revokes +/// the whole chain. +fn credential_consumed(err: &crate::AuthError) -> bool { + matches!(err, crate::AuthError::Store(_)) +} + +/// Ensures [`AutoRefresh::refresh_in_progress`] is cleared and waiters are +/// notified if the refresh future is cancelled (dropped) before completing. +/// +/// On the normal path (success or handled error), the guard is defused before +/// drop so that the regular cleanup code runs instead. +/// +/// Unlike the normal paths, `Drop` is synchronous and so notifies without +/// taking the state lock. A caller that has read `refresh_in_progress` as +/// `true` and is on its way into +/// [`wait_for_in_flight_refresh`](AutoRefresh::wait_for_in_flight_refresh) +/// holds that lock, which does not block this notification — so if the refresh +/// future is cancelled in that window, the wake still lands on an empty list +/// and that caller parks with nothing left to wake it. The `enable()` call in +/// `wait_for_in_flight_refresh` does not close this narrower variant; doing so +/// needs the notify moved under the state lock or a bounded wait, and is +/// tracked separately. +struct CancelGuard<'a> { + in_progress: &'a AtomicBool, + notify: &'a Notify, + defused: bool, +} + +impl Drop for CancelGuard<'_> { + fn drop(&mut self) { + if !self.defused { + self.in_progress.store(false, Ordering::Release); + self.notify.notify_waiters(); + } + } +} + +impl CancelGuard<'_> { + fn defuse(&mut self) { + self.defused = true; + } +} + +impl State { + fn new(token: Option<Token>) -> Self { + Self { + token, + denial: None, + last_refresh_error: None, + } + } + + /// Record the outcome of a failed refresh attempt. Every failure path + /// calls this exactly once, so the two things a failure needs to update + /// can't drift apart by a call site remembering one and not the other: + /// + /// - if `err` refuses the *account* rather than the credential, it + /// becomes the sticky, TTL'd [`denial`](Self::fresh_denial) replayed to + /// this refresher's own future `get_token` calls. A usage limit is + /// different in kind from most non-retryable failures: the credential + /// was never the problem, so re-presenting it cannot change the answer. + /// See [`AuthError::is_account_refusal`] for why this is narrower than + /// [`is_retryable`](crate::AuthError::is_retryable). + /// - `err` always becomes [`last_refresh_error`](Self::last_refresh_error), + /// regardless of its class, for any caller parked in + /// `wait_for_in_flight_refresh` to see the same answer this refresh + /// attempt actually got. + fn record_refusal(&mut self, err: &crate::AuthError, now: u64) { + if err.is_account_refusal() { + self.denial = Some(StickyDenial::new(err, now)); + } + self.last_refresh_error = Some((err.error_code(), err.to_string())); + } + + /// The error from the most recently completed refresh attempt, if it + /// failed and no success has happened since. + fn last_refresh_error(&self) -> Option<crate::AuthError> { + self.last_refresh_error.as_ref().map(|(code, message)| { + crate::AuthError::from_error_code(code, message, &serde_json::Map::new()) + }) + } + + /// The recorded refusal if it is still within its window, discarding it if + /// not so the next attempt goes back to the network. + fn fresh_denial(&mut self, now: u64) -> Option<crate::AuthError> { + match &self.denial { + Some(denial) if !denial.is_stale(now) => Some(denial.to_error()), + Some(_) => { + self.denial = None; + None + } + None => None, + } + } + + fn service_token(&self) -> Result<ServiceToken, AutoRefreshError> { + let token = self.token.as_ref().ok_or(AutoRefreshError::NotFound)?; + Ok(ServiceToken::new(token.access_token().clone())) + } + + fn require_usable_token(&self, now: u64) -> Result<ServiceToken, AutoRefreshError> { + let token = self.token.as_ref().ok_or(AutoRefreshError::NotFound)?; + if token.is_usable_at(now) { + Ok(ServiceToken::new(token.access_token().clone())) + } else { + Err(AutoRefreshError::Expired) + } + } +} + +impl<R> AutoRefresh<R, NoStore> { + /// Create a new `AutoRefresh` with a pre-loaded token and no external store. + /// + /// Use this for refreshers that cannot self-authenticate (e.g. OAuth, + /// which needs a refresh token from a prior device code flow). + pub(crate) fn with_token(refresher: R, token: Token) -> Self { + Self { + refresher, + state: Mutex::new(State::new(Some(token))), + store: NoStore, + refresh_in_progress: AtomicBool::new(false), + refresh_notify: Notify::new(), + clock: system_clock(), + } + } + + /// Like [`with_token`](Self::with_token) but with an injected clock, so tests + /// can drive token expiry deterministically. + #[cfg(test)] + #[cfg(feature = "http")] + pub(crate) fn with_token_and_clock(refresher: R, token: Token, clock: SharedClock) -> Self { + Self { + refresher, + state: Mutex::new(State::new(Some(token))), + store: NoStore, + refresh_in_progress: AtomicBool::new(false), + refresh_notify: Notify::new(), + clock, + } + } +} + +impl<R, S: TokenStore> AutoRefresh<R, S> { + /// Create a new `AutoRefresh` backed by `store` and no in-memory token. + /// + /// On the first `get_token` call the store is consulted before falling + /// through to initial authentication via `try_credential(None)`; every + /// successful refresh writes the new token back via `store.save()`. Pass + /// [`NoStore`] for the no-external-cache case — it's the default and + /// elides to a zero-cost no-op. + pub(crate) fn with_store(refresher: R, store: S) -> Self { + Self { + refresher, + state: Mutex::new(State::new(None)), + store, + refresh_in_progress: AtomicBool::new(false), + refresh_notify: Notify::new(), + clock: system_clock(), + } + } +} + +impl<R: Refresher, S: TokenStore> AutoRefresh<R, S> { + /// Retrieve a valid access token, refreshing or re-authenticating as needed. + pub(crate) async fn get_token(&self) -> Result<ServiceToken, AutoRefreshError> { + let mut state = self.state.lock().await; + + if state.token.is_none() { + // A settled account-level refusal is checked before the store + // read: without this, every `get_token` call during the denial + // window turns the suppressed HTTP storm against CTS into an + // identical storm against the caller's own store backend instead. + if let Some(err) = state.fresh_denial(self.clock.now_unix_secs()) { + return Err(AutoRefreshError::Auth(err)); + } + // Drop the lock for the store read so a slow user-supplied backend + // (cookie, KV, Redis) doesn't serialise concurrent `get_token` + // callers. Re-acquire and double-check `state.token.is_none()` in + // case another caller populated it while we awaited. + drop(state); + let loaded = self.store.load().await; + state = self.state.lock().await; + if state.token.is_none() { + state.token = loaded; + } + } + + // Read "now" once from the injected clock and use it for every expiry + // decision in this call — token expiry and refusal expiry alike — so + // the checks are mutually consistent and deterministic under test. + let now = self.clock.now_unix_secs(); + + if state.token.is_none() { + if let Some(err) = state.fresh_denial(now) { + return Err(AutoRefreshError::Auth(err)); + } + return self.initial_auth(&mut state).await; + } + + if !state.token.as_ref().is_some_and(|t| t.is_expired_at(now)) { + return state.service_token(); + } + + if self.refresh_in_progress.load(Ordering::Acquire) { + return self.wait_for_in_flight_refresh(state, now).await; + } + + // A settled refusal stands until something outside this client changes. + // Checked before `try_credential`, which moves the credential out of + // the cached token and would need restoring on an early return. + if let Some(err) = state.fresh_denial(now) { + return if state.token.as_ref().is_some_and(|t| t.is_usable_at(now)) { + state.service_token() + } else { + Err(AutoRefreshError::Auth(err)) + }; + } + + let Some(credential) = self.refresher.try_credential(state.token.as_mut()) else { + return state.require_usable_token(now); + }; + + self.refresh_in_progress.store(true, Ordering::Release); + + if state.token.as_ref().is_some_and(|t| t.is_usable_at(now)) { + self.refresh_non_blocking(state, credential).await + } else { + self.refresh_blocking(&mut state, credential).await + } + } + + /// No cached token — authenticate via `try_credential(None)`. + /// + /// The lock is held throughout to prevent concurrent initial-auth attempts. + async fn initial_auth(&self, state: &mut State) -> Result<ServiceToken, AutoRefreshError> { + let Some(credential) = self.refresher.try_credential(None) else { + return Err(AutoRefreshError::NotFound); + }; + self.refresh_in_progress.store(true, Ordering::Release); + let mut guard = CancelGuard { + in_progress: &self.refresh_in_progress, + notify: &self.refresh_notify, + defused: false, + }; + match self.refresher.refresh(&credential).await { + Ok(new_token) => { + self.save_refreshed_token(&new_token).await; + let token = self.install_refreshed_token(state, new_token); + guard.defuse(); + Ok(token) + } + Err(err) => { + guard.defuse(); + self.refresh_in_progress.store(false, Ordering::Release); + state.record_refusal(&err, self.clock.now_unix_secs()); + Err(AutoRefreshError::Auth(err)) + } + } + } + + /// Persist a freshly refreshed token to the per-refresher sink and the + /// user-supplied `TokenStore`. Awaits the store write, so callers should + /// drop the state lock before invoking this where possible (the + /// non-blocking refresh path does; the blocking/initial paths hold the + /// state lock throughout by design). + async fn save_refreshed_token(&self, new_token: &Token) { + self.refresher.save(new_token); + self.store.save(new_token).await; + } + + /// Install a freshly refreshed token in `state`, clear the in-progress + /// flag, and return the corresponding [`ServiceToken`]. Pure in-lock + /// work; caller is responsible for having already persisted the token via + /// [`save_refreshed_token`](Self::save_refreshed_token). + fn install_refreshed_token(&self, state: &mut State, new_token: Token) -> ServiceToken { + let service_token = ServiceToken::new(new_token.access_token().clone()); + state.token = Some(new_token); + // A success proves whatever previously refused us has changed its mind. + state.denial = None; + state.last_refresh_error = None; + self.refresh_in_progress.store(false, Ordering::Release); + service_token + } + + /// Another caller is already refreshing — return the current token if still + /// usable, otherwise wait for the in-flight refresh to complete via `Notify`. + /// + /// Takes `MutexGuard` by value because the lock is dropped before awaiting + /// the notification. + async fn wait_for_in_flight_refresh( + &self, + state: MutexGuard<'_, State>, + now: u64, + ) -> Result<ServiceToken, AutoRefreshError> { + if let Ok(token) = state.service_token() { + if state.token.as_ref().is_some_and(|t| t.is_usable_at(now)) { + return Ok(token); + } + } + // Token crossed real expiry during in-flight refresh. Wait for the + // refresh to complete rather than returning Expired. + // + // `Notified` does not join the notify list until it is first polled, + // and `notify_waiters` stores no permit for futures that are not yet + // on it. Registering only at `.await` would leave a window after the + // lock drops in which the in-flight refresh can complete, notify an + // empty list, and leave this caller parked until some later refresh + // cycle notifies again — which for an idle client may be never. + // `enable()` joins the list while the state lock is still held, and + // `refresh_non_blocking` takes that same lock to record its outcome + // before it notifies, so the notification cannot land before we are + // listed. This does not cover `CancelGuard::drop`, which notifies + // without the lock — see the note on that impl. + let mut notified = std::pin::pin!(self.refresh_notify.notified()); + // The `bool` reports whether a stored permit was consumed, which only + // `notify_one` produces; this `Notify` is only ever driven by + // `notify_waiters`, so there is nothing to act on. + let _ = notified.as_mut().enable(); + drop(state); + notified.await; + // Re-check after wake — refresh may have failed. Re-read the clock: an + // arbitrary amount of time may have passed while awaiting the refresh. + let now = self.clock.now_unix_secs(); + let mut state = self.state.lock().await; + match state.require_usable_token(now) { + Ok(token) => Ok(token), + // The refresh we waited on may have failed with a refusal that no + // retry clears. It is already recorded — `refresh_non_blocking` + // records before `notify_waiters` wakes us — so reporting + // `Expired` here would tell every caller *except* the one that + // issued the request that their token lapsed. That is the same + // misdiagnosis `refresh_blocking` stopped making, reached by a + // different route: it sends the caller round the refresh loop + // that just failed, for a condition only a plan upgrade or + // support can clear. + // + // A still-usable token still wins, matching the pre-refresh path + // in `get_token`: a settled refusal suppresses further requests, + // it does not invalidate a credential that still works. + Err(unusable) => match state.fresh_denial(now) { + Some(err) => Err(AutoRefreshError::Auth(err)), + // Not an account-level refusal (or none was recorded) — fall + // back to whatever the refresh actually returned, so this + // caller sees the same typed error the issuer would have + // (e.g. `invalid_grant`, a rotated/revoked refresh token) + // rather than a generic `Expired` that discards it. Skipped + // when the recorded error already *is* `TokenExpired` — that + // degrades to the same `AuthError` as `unusable` once + // unwrapped, so there's nothing more specific to surface. + None => match state.last_refresh_error() { + Some(err) if !matches!(err, crate::AuthError::TokenExpired(_)) => { + Err(AutoRefreshError::Auth(err)) + } + _ => Err(unusable), + }, + }, + } + } + + /// Token is expiring but still usable — drop the lock, refresh in the + /// background of this call, and return the old (still-valid) token. + /// + /// Takes `MutexGuard` by value because the lock is dropped before the HTTP + /// request. Notifies waiters after the refresh completes (success or error). + /// + /// A [`CancelGuard`] ensures that if this future is cancelled at any point + /// before the new token is installed — including the post-HTTP save + + /// install window — `refresh_in_progress` is cleared and waiters are + /// notified, so subsequent callers don't hang in + /// [`wait_for_in_flight_refresh`](Self::wait_for_in_flight_refresh). + /// The credential is not restored on cancellation (it's already gone from + /// `state.token`), so the next caller will get whatever the cached token + /// offers — usable, expired, or absent. + async fn refresh_non_blocking( + &self, + state: MutexGuard<'_, State>, + credential: R::Credential, + ) -> Result<ServiceToken, AutoRefreshError> { + let current_service_token = state.service_token()?; + drop(state); + + let mut guard = CancelGuard { + in_progress: &self.refresh_in_progress, + notify: &self.refresh_notify, + defused: false, + }; + + let result = match self.refresher.refresh(&credential).await { + Ok(new_token) => { + self.save_refreshed_token(&new_token).await; + let mut state = self.state.lock().await; + let _ = self.install_refreshed_token(&mut state, new_token); + guard.defuse(); + Ok(current_service_token) + } + Err(err) => { + let consumed = credential_consumed(&err); + if consumed { + tracing::error!(%err, "refreshed token could not be persisted"); + } else { + tracing::warn!(%err, "token refresh failed (token still usable)"); + } + // Defer `defuse()` until after the lock acquire so the + // CancelGuard's Drop still fires if cancellation lands on + // `state.lock().await`. Without this the in-progress flag + // would stay set with no `notify_waiters`, wedging every + // subsequent caller exactly like the Ok-path bug fixed + // earlier in this file. + let mut state = self.state.lock().await; + if !consumed { + if let Some(token) = state.token.as_mut() { + self.refresher.restore(token, credential); + } + } + self.refresh_in_progress.store(false, Ordering::Release); + // Record the refusal so the next call doesn't re-issue the + // same request (if it's account-level), and so a caller parked + // in `wait_for_in_flight_refresh` sees the same answer this + // refresh actually got (regardless of its class). + state.record_refusal(&err, self.clock.now_unix_secs()); + guard.defuse(); + // An ordinary failure leaves the cached token usable, so this + // call still succeeds. A consumed credential does not: the + // rotation happened upstream but was lost, and succeeding + // would hide that until the cached token expires. + if consumed { + Err(AutoRefreshError::Auth(err)) + } else { + Ok(current_service_token) + } + } + }; + + self.refresh_notify.notify_waiters(); + result + } + + /// Token is fully expired — refresh while holding the lock so concurrent + /// callers block on `lock().await` until the new token is available. + /// + /// A [`CancelGuard`] ensures that if this future is cancelled at any point + /// before the new token is installed — including the post-HTTP save + /// window — `refresh_in_progress` is cleared and waiters are notified so + /// they don't hang indefinitely. (The credential is lost on cancel — + /// see [`CancelGuard`] docs — but subsequent callers will get `Expired` + /// rather than blocking forever.) + async fn refresh_blocking( + &self, + state: &mut State, + credential: R::Credential, + ) -> Result<ServiceToken, AutoRefreshError> { + let mut guard = CancelGuard { + in_progress: &self.refresh_in_progress, + notify: &self.refresh_notify, + defused: false, + }; + match self.refresher.refresh(&credential).await { + Ok(new_token) => { + self.save_refreshed_token(&new_token).await; + let token = self.install_refreshed_token(state, new_token); + guard.defuse(); + Ok(token) + } + Err(err) => { + guard.defuse(); + tracing::warn!(%err, "token refresh failed"); + if !credential_consumed(&err) { + if let Some(token) = state.token.as_mut() { + self.refresher.restore(token, credential); + } + } + self.refresh_in_progress.store(false, Ordering::Release); + state.record_refusal(&err, self.clock.now_unix_secs()); + // Propagate the refuser's own answer. Flattening to `Expired` + // here would tell a caller who is over their usage limit that + // their token expired, and send them round the same loop. + Err(AutoRefreshError::Auth(err)) + } + } + } +} + +#[cfg(test)] +#[cfg(feature = "http")] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::device_session_refresher::DeviceSessionRefresher; + use crate::SecretToken; + use mocktail::prelude::*; + use stack_profile::ProfileStore; + use std::sync::Arc; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn auto_refresh_error_maps_to_public_auth_error() { + use crate::AuthError; + assert!(matches!( + AuthError::from(AutoRefreshError::NotFound), + AuthError::NotAuthenticated(_) + )); + assert!(matches!( + AuthError::from(AutoRefreshError::Expired), + AuthError::TokenExpired(_) + )); + // The `Auth` variant passes the inner error through unchanged. + assert!(matches!( + AuthError::from(AutoRefreshError::Auth(AuthError::AccessDenied( + crate::error::AccessDenied + ))), + AuthError::AccessDenied(_) + )); + } + + /// The guard's contract, directly: armed, its drop clears the in-progress + /// flag (the cancellation path); defused, its drop leaves the flag to + /// the normal path that already owns it. A defused guard that still + /// fired would clear the flag out from under a refresh another caller + /// started after this one installed its token. + #[test] + fn a_defused_cancel_guard_leaves_the_flag_alone() { + let in_progress = AtomicBool::new(true); + let notify = Notify::new(); + + let mut guard = CancelGuard { + in_progress: &in_progress, + notify: &notify, + defused: false, + }; + guard.defuse(); + drop(guard); + assert!( + in_progress.load(Ordering::Acquire), + "a defused guard must not touch the flag" + ); + + drop(CancelGuard { + in_progress: &in_progress, + notify: &notify, + defused: false, + }); + assert!( + !in_progress.load(Ordering::Acquire), + "an armed guard clears the flag on drop" + ); + } + + fn make_token(access: &str, expires_in: u64, refresh: bool) -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + Token { + access_token: SecretToken::new(access), + token_type: "Bearer".to_string(), + expires_at: now + expires_in, + refresh_token: if refresh { + Some(SecretToken::new("test-refresh-token")) + } else { + None + }, + region: None, + client_id: None, + device_instance_id: None, + } + } + + fn refresh_response_json(access: &str) -> serde_json::Value { + serde_json::json!({ + "access_token": access, + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh-token" + }) + } + + fn error_json(error: &str) -> serde_json::Value { + serde_json::json!({ + "error": error, + "error_description": format!("{error} occurred") + }) + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("auto-refresh-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + fn auto_refresh_with_token( + dir: &tempfile::TempDir, + server: &MockServer, + token: Token, + ) -> AutoRefresh<DeviceSessionRefresher> { + let store = ProfileStore::new(dir.path()); + store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store.save_profile(&token).unwrap(); + let refresher = DeviceSessionRefresher::new( + Some(ws_store), + server.url(""), + "cli", + "ap-southeast-2.aws", + None, + crate::transport::default_transport(), + ); + AutoRefresh::with_token(refresher, token) + } + + mod given_no_cached_token { + use super::*; + + #[tokio::test] + async fn returns_not_found_for_oauth() { + let server = start_server(MockSet::new()).await; + let store = ProfileStore::new("/tmp/nonexistent"); + let refresher = DeviceSessionRefresher::new( + Some(store), + server.url(""), + "cli", + "ap-southeast-2.aws", + None, + crate::transport::default_transport(), + ); + let strategy = AutoRefresh::with_store(refresher, NoStore); + + let err = strategy.get_token().await.unwrap_err(); + + assert!( + matches!(err, AutoRefreshError::NotFound), + "expected NotFound, got: {err:?}" + ); + } + } + + /// A settled account-level denial must short-circuit before the store is + /// consulted, not just before the network call to CTS. Otherwise every + /// `get_token` during the denial window turns the suppressed HTTP storm + /// against CTS into an identical storm against the caller's own store + /// backend (a cookie, a KV store, Redis) for the whole window instead. + mod given_a_fresh_sticky_denial_and_no_cached_token { + use super::*; + use crate::token_store::TokenStore; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// `TokenStore` that always misses, counting how many times `load` is + /// called. + struct CountingStore { + load_calls: Arc<AtomicUsize>, + } + + impl TokenStore for CountingStore { + async fn load(&self) -> Option<Token> { + self.load_calls.fetch_add(1, Ordering::SeqCst); + None + } + + async fn save(&self, _token: &Token) {} + } + + /// `Refresher` that can authenticate from cold (no prior token) but + /// always has its refresh refused as over the usage limit. + struct AlwaysOverLimitRefresher; + + impl Refresher for AlwaysOverLimitRefresher { + type Credential = (); + + fn save(&self, _token: &Token) {} + + fn try_credential(&self, _token: Option<&mut Token>) -> Option<Self::Credential> { + Some(()) + } + + fn restore(&self, _token: &mut Token, _credential: Self::Credential) {} + + async fn refresh( + &self, + _credential: &Self::Credential, + ) -> Result<Token, crate::AuthError> { + Err(crate::AuthError::UsageLimitExceeded( + crate::error::UsageLimitExceeded("over limit".to_string()), + )) + } + } + + #[tokio::test] + async fn does_not_re_hit_the_store_while_the_denial_is_fresh() { + let load_calls = Arc::new(AtomicUsize::new(0)); + let store = CountingStore { + load_calls: Arc::clone(&load_calls), + }; + let strategy = AutoRefresh::with_store(AlwaysOverLimitRefresher, store); + + let first = strategy.get_token().await; + assert!( + matches!( + first, + Err(AutoRefreshError::Auth( + crate::AuthError::UsageLimitExceeded(_) + )) + ), + "expected UsageLimitExceeded, got: {first:?}" + ); + assert_eq!( + load_calls.load(Ordering::SeqCst), + 1, + "the first call has no denial recorded yet, so it must still consult the store" + ); + + let second = strategy.get_token().await; + assert!( + matches!( + second, + Err(AutoRefreshError::Auth( + crate::AuthError::UsageLimitExceeded(_) + )) + ), + "expected UsageLimitExceeded, got: {second:?}" + ); + assert_eq!( + load_calls.load(Ordering::SeqCst), + 1, + "a fresh sticky denial must short-circuit before the store is consulted again" + ); + } + } + + mod given_fresh_token { + use super::*; + + #[tokio::test] + async fn returns_cached_token() { + let dir = tempfile::tempdir().unwrap(); + let server = start_server(MockSet::new()).await; + let strategy = + auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false)); + + let token = strategy.get_token().await.unwrap(); + + assert_eq!( + token.as_str(), + "my-access-token", + "should return the cached access token" + ); + } + + #[tokio::test] + async fn caches_across_calls() { + let dir = tempfile::tempdir().unwrap(); + let server = start_server(MockSet::new()).await; + let strategy = + auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false)); + + let token1 = strategy.get_token().await.unwrap(); + assert_eq!( + token1.as_str(), + "my-access-token", + "first call should return the cached token" + ); + + // Delete the file — second call should still return the cached token. + std::fs::remove_file( + dir.path() + .join("workspaces") + .join("ZVATKW3VHMFG27DY") + .join("auth.json"), + ) + .unwrap(); + + let token2 = strategy.get_token().await.unwrap(); + assert_eq!( + token2.as_str(), + "my-access-token", + "second call should return the cached token even after file deletion" + ); + } + + #[tokio::test] + async fn does_not_trigger_refresh() { + // Mock that would fail if hit — proves no refresh request is made. + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.internal_server_error() + .json(error_json("should_not_be_called")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("fresh-token", 3600, true)); + + let token = strategy.get_token().await.unwrap(); + + assert_eq!( + token.as_str(), + "fresh-token", + "should return fresh token without triggering refresh" + ); + } + } + + mod given_fully_expired_token { + use super::*; + + mod without_refresh_token { + use super::*; + + #[tokio::test] + async fn returns_expired() { + let dir = tempfile::tempdir().unwrap(); + let server = start_server(MockSet::new()).await; + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, false)); + + let err = strategy.get_token().await.unwrap_err(); + + assert!( + matches!(err, AutoRefreshError::Expired), + "expected Expired, got: {err:?}" + ); + } + } + + mod with_refresh_token { + use super::*; + + #[tokio::test] + async fn refreshes_and_returns_new_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + + let token = strategy.get_token().await.unwrap(); + + assert_eq!( + token.as_str(), + "refreshed-token", + "should return the refreshed token" + ); + } + + #[tokio::test] + async fn persists_refreshed_token_to_disk() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + + let _ = strategy.get_token().await.unwrap(); + + // Verify the refreshed token was saved to the workspace directory. + let store = ProfileStore::new(dir.path()); + let ws_store = store.current_workspace_store().unwrap(); + let on_disk: Token = ws_store.load_profile().unwrap(); + assert_eq!( + on_disk.access_token().as_str(), + "refreshed-token", + "refreshed token should be persisted to disk" + ); + } + + #[tokio::test] + async fn returns_the_servers_refusal_on_refresh_failure() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + + let err = strategy.get_token().await.unwrap_err(); + + assert!( + matches!( + err, + AutoRefreshError::Auth(crate::AuthError::InvalidGrant(_)) + ), + "the caller must see the grant was rejected, not a generic \ + Expired that invites the same doomed retry: {err:?}" + ); + } + + #[tokio::test] + async fn restores_refresh_token_after_failure() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + + // First call: refresh fails and the rejection reaches the caller. + let err = strategy.get_token().await.unwrap_err(); + assert!( + matches!( + err, + AutoRefreshError::Auth(crate::AuthError::InvalidGrant(_)) + ), + "expected the grant rejection on first attempt, got: {err:?}" + ); + + // Verify the refresh token was restored so a retry is possible. + let state = strategy.state.lock().await; + assert!( + state.token.is_some(), + "token should still be cached after failed refresh" + ); + assert!( + state.token.as_ref().unwrap().refresh_token().is_some(), + "refresh token should be restored for retry" + ); + drop(state); + + // Replace mock with a success response. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + + // Second call: refresh token is available → retry succeeds. + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "refreshed-token", + "retry should succeed with restored refresh token" + ); + } + + #[tokio::test] + async fn sequential_calls_only_refresh_once() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-once")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + + // First call triggers refresh. + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "refreshed-once", + "first call should trigger refresh" + ); + + // Swap mock to track if another refresh is attempted. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-twice")); + }); + + // Calls 2-5: the refreshed token is fresh, so no further refresh. + for _ in 0..4 { + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "refreshed-once", + "should return cached refreshed token, not trigger another refresh" + ); + } + } + + #[tokio::test] + async fn prevents_second_refresh_after_success() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + + // First call refreshes successfully. + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "refreshed-token", + "first call should refresh the token" + ); + + // Replace the mock with one that errors. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("should_not_be_called")); + }); + + // Second call should return the refreshed token without hitting + // the server again (the new token has a fresh expiry). + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "refreshed-token", + "second call should return cached refreshed token" + ); + } + } + } + + mod given_expiring_but_usable_token { + use super::*; + + mod when_refresh_fails { + use super::*; + + #[tokio::test] + async fn returns_current_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("server_error")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + // Token expires in 30s (within the 90s leeway so is_expired() = true), + // but the access token is still technically usable. + let strategy = + auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true)); + + // The refresh fails, but the access token should still be returned + // because it's still usable (30s remaining > 0). + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "still-usable", + "should return still-usable token despite failed refresh" + ); + + // Verify the access token and refresh token are still present. + let state = strategy.state.lock().await; + assert!(state.token.is_some(), "token should still be cached"); + assert_eq!( + state.token.as_ref().unwrap().access_token().as_str(), + "still-usable", + "access token should be unchanged after failed refresh" + ); + assert!( + state.token.as_ref().unwrap().refresh_token().is_some(), + "refresh token should be restored after failed refresh" + ); + } + + #[tokio::test] + async fn restores_refresh_token_for_retry() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("server_error")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + // Token expires in 30s — is_expired() = true, is_usable() = true. + let strategy = + auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true)); + + // First call: refresh fails, but the still-usable token is returned. + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "still-usable", + "first call should return still-usable token" + ); + + // Replace mock with a success response. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + + // Second call: refresh token was restored, so the retry succeeds. + let token = strategy.get_token().await.unwrap(); + assert!( + token.as_str() == "still-usable" || token.as_str() == "refreshed-token", + "expected old or refreshed token, got: {}", + token.as_str() + ); + + // Verify the cache now holds the refreshed token. + let state = strategy.state.lock().await; + assert_eq!( + state.token.as_ref().unwrap().access_token().as_str(), + "refreshed-token", + "cache should hold the refreshed token after retry" + ); + } + } + } + + /// Makes every later `auth.json` write fail: the atomic save renames a + /// temp file over the target, which cannot replace a non-empty directory. + fn break_token_persistence(dir: &tempfile::TempDir) { + use stack_profile::ProfileData; + let path = ProfileStore::new(dir.path()) + .workspace_store("ZVATKW3VHMFG27DY") + .unwrap() + .dir() + .join(Token::FILENAME); + std::fs::remove_file(&path).unwrap(); + std::fs::create_dir(&path).unwrap(); + std::fs::write(path.join("occupied"), b"").unwrap(); + } + + fn refreshing_server() -> MockSet { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + mocks + } + + /// The upstream exchange succeeds, so the refresh token it sent is spent. + /// Restoring it would replay it on the next call and revoke the chain. + mod given_a_refreshed_token_cannot_be_saved { + use super::*; + use crate::AuthError; + + #[tokio::test] + async fn an_expiring_token_fails_the_call_and_drops_the_spent_refresh_token() { + let server = start_server(refreshing_server()).await; + let dir = tempfile::tempdir().unwrap(); + // Inside the refresh leeway but still usable: the non-blocking path. + let strategy = + auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true)); + break_token_persistence(&dir); + + let result = strategy.get_token().await; + assert!( + matches!(result, Err(AutoRefreshError::Auth(AuthError::Store(_)))), + "a lost rotation must fail the call, got: {result:?}" + ); + + let state = strategy.state.lock().await; + assert!( + state.token.as_ref().unwrap().refresh_token().is_none(), + "the spent refresh token must not be restored for replay" + ); + } + + #[tokio::test] + async fn an_expired_token_fails_the_call_and_drops_the_spent_refresh_token() { + let server = start_server(refreshing_server()).await; + let dir = tempfile::tempdir().unwrap(); + let mut token = make_token("expired", 0, true); + token.expires_at -= 10; + let strategy = auto_refresh_with_token(&dir, &server, token); + break_token_persistence(&dir); + + let result = strategy.get_token().await; + assert!( + matches!(result, Err(AutoRefreshError::Auth(AuthError::Store(_)))), + "a lost rotation must fail the call, got: {result:?}" + ); + + let state = strategy.state.lock().await; + assert!( + state.token.as_ref().unwrap().refresh_token().is_none(), + "the spent refresh token must not be restored for replay" + ); + } + } + + mod given_concurrent_callers { + use super::*; + + #[tokio::test] + async fn returns_usable_token_while_refreshing() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &server, + make_token("still-usable", 30, true), + )); + + let s1 = Arc::clone(&strategy); + let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); + + let s2 = Arc::clone(&strategy); + let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); + + let (result_a, result_b) = tokio::join!(handle_a, handle_b); + let token_a = result_a.unwrap(); + let token_b = result_b.unwrap(); + + assert!( + token_a.as_str() == "still-usable" || token_a.as_str() == "refreshed-token", + "unexpected token_a: {}", + token_a.as_str() + ); + assert!( + token_b.as_str() == "still-usable" || token_b.as_str() == "refreshed-token", + "unexpected token_b: {}", + token_b.as_str() + ); + } + + #[tokio::test] + async fn blocks_until_refresh_completes() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &server, + make_token("expired-token", 0, true), + )); + + let s1 = Arc::clone(&strategy); + let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); + + let s2 = Arc::clone(&strategy); + let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); + + let (result_a, result_b) = tokio::join!(handle_a, handle_b); + let token_a = result_a.unwrap(); + let token_b = result_b.unwrap(); + + assert_eq!( + token_a.as_str(), + "refreshed-token", + "caller a should receive refreshed token" + ); + assert_eq!( + token_b.as_str(), + "refreshed-token", + "caller b should receive refreshed token" + ); + } + } +} + +#[cfg(test)] +#[cfg(feature = "http")] +#[allow(clippy::unwrap_used)] +mod stress_tests { + use super::*; + use crate::device_session_refresher::DeviceSessionRefresher; + use crate::SecretToken; + use stack_profile::ProfileStore; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + + /// Tracks in-flight and peak concurrency for test assertions. + #[derive(Clone)] + struct CountingState { + total: Arc<AtomicUsize>, + current: Arc<AtomicUsize>, + peak: Arc<AtomicUsize>, + } + + impl CountingState { + fn new() -> Self { + Self { + total: Arc::new(AtomicUsize::new(0)), + current: Arc::new(AtomicUsize::new(0)), + peak: Arc::new(AtomicUsize::new(0)), + } + } + + fn enter(&self) { + self.total.fetch_add(1, Ordering::SeqCst); + let prev = self.current.fetch_add(1, Ordering::SeqCst); + self.peak.fetch_max(prev + 1, Ordering::SeqCst); + } + + fn exit(&self) { + self.current.fetch_sub(1, Ordering::SeqCst); + } + + fn peak(&self) -> usize { + self.peak.load(Ordering::SeqCst) + } + + fn total(&self) -> usize { + self.total.load(Ordering::SeqCst) + } + } + + #[derive(Clone)] + struct DelayedRefreshState { + counting: CountingState, + delay: Duration, + } + + async fn delayed_refresh_handler( + axum::extract::State(state): axum::extract::State<DelayedRefreshState>, + ) -> axum::Json<serde_json::Value> { + state.counting.enter(); + tokio::time::sleep(state.delay).await; + state.counting.exit(); + axum::Json(serde_json::json!({ + "access_token": "refreshed-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh-token" + })) + } + + async fn delayed_error_handler( + axum::extract::State(state): axum::extract::State<DelayedRefreshState>, + ) -> (axum::http::StatusCode, axum::Json<serde_json::Value>) { + state.counting.enter(); + tokio::time::sleep(state.delay).await; + state.counting.exit(); + ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": "invalid_grant", + "error_description": "invalid_grant occurred" + })), + ) + } + + async fn start_axum_server<H, T>( + handler: H, + state: DelayedRefreshState, + ) -> (url::Url, CountingState) + where + H: axum::handler::Handler<T, DelayedRefreshState> + Clone + Send + 'static, + T: 'static, + { + let counting = state.counting.clone(); + let app = axum::Router::new() + .route("/oauth/token", axum::routing::post(handler)) + .with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let base_url = url::Url::parse(&format!("http://{addr}")).unwrap(); + (base_url, counting) + } + + fn make_token(access: &str, expires_in: u64, refresh: bool) -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + Token { + access_token: SecretToken::new(access), + token_type: "Bearer".to_string(), + expires_at: now + expires_in, + refresh_token: if refresh { + Some(SecretToken::new("test-refresh-token")) + } else { + None + }, + region: None, + client_id: None, + device_instance_id: None, + } + } + + fn auto_refresh_with_token( + dir: &tempfile::TempDir, + base_url: &url::Url, + token: Token, + ) -> AutoRefresh<DeviceSessionRefresher> { + let store = ProfileStore::new(dir.path()); + store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store.save_profile(&token).unwrap(); + let refresher = DeviceSessionRefresher::new( + Some(ws_store), + base_url.clone(), + "cli", + "ap-southeast-2.aws", + None, + crate::transport::default_transport(), + ); + AutoRefresh::with_token(refresher, token) + } + + const CONCURRENCY: usize = 50; + + mod given_fresh_token { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn all_callers_return_immediately() { + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_millis(500), + }; + let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("fresh-token", 3600, true), + )); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + for token in &results { + assert_eq!( + token.as_str(), + "fresh-token", + "all callers should receive the fresh token" + ); + } + + assert!( + elapsed < Duration::from_millis(200), + "expected < 200ms for fresh tokens, got {:?}", + elapsed + ); + assert_eq!(stats.total(), 0, "no refresh requests should be made"); + } + } + + mod given_expiring_but_usable_token { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn non_blocking_reads_during_refresh() { + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_millis(500), + }; + let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("still-usable", 30, true), + )); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { + let call_start = Instant::now(); + let token = s.get_token().await.unwrap(); + (token, call_start.elapsed()) + })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + for (token, _) in &results { + assert!( + token.as_str() == "still-usable" || token.as_str() == "refreshed-token", + "unexpected token: {}", + token.as_str() + ); + } + + let fast_callers = results + .iter() + .filter(|(_, dur)| *dur < Duration::from_millis(100)) + .count(); + assert!( + fast_callers >= CONCURRENCY - 1, + "expected at least {} fast callers, got {} (total elapsed: {:?})", + CONCURRENCY - 1, + fast_callers, + elapsed + ); + + assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); + assert_eq!(stats.total(), 1, "total refresh requests"); + } + } + + mod given_fully_expired_token { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn all_callers_block_until_refresh() { + let refresh_delay = Duration::from_millis(200); + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: refresh_delay, + }; + let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("expired-token", 0, true), + )); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + for token in &results { + assert_eq!( + token.as_str(), + "refreshed-token", + "all callers should receive refreshed token" + ); + } + + assert!( + elapsed < refresh_delay + Duration::from_millis(200), + "expected < {:?} for blocked callers, got {:?}", + refresh_delay + Duration::from_millis(200), + elapsed + ); + + assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); + assert_eq!(stats.total(), 1, "total refresh requests"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn all_callers_receive_an_error_on_failure() { + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_millis(10), + }; + let (base_url, stats) = start_axum_server(delayed_error_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("expired-token", 0, true), + )); + + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + + // No caller may come away with a token. A fully-expired token + // takes the *blocking* refresh path, which holds the state lock + // across the whole HTTP call — so no other caller can ever be + // concurrently parked in `wait_for_in_flight_refresh` while it + // runs; every one of them queues on the lock itself and, on + // acquiring it, performs (and fails) its own refresh in turn. So + // every caller here sees the server's actual refusal directly, + // not a generic `Expired` — this is asserted precisely, not just + // "at least one caller does", so a change that lets some caller + // fall through to `Expired` is caught. + for result in &results { + assert!( + matches!( + result, + Err(AutoRefreshError::Auth(crate::AuthError::InvalidGrant(_))) + ), + "every caller must receive the server's actual refusal, not a \ + generic Expired: {result:?}" + ); + } + + let state = strategy.state.lock().await; + assert!( + state.token.as_ref().unwrap().refresh_token().is_some(), + "refresh token should be restored after failed refresh" + ); + drop(state); + + assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); + assert!( + stats.total() >= 1, + "at least one refresh attempt should be made" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn retry_succeeds_after_failure() { + // Phase 1: Server returns errors. + let counting1 = CountingState::new(); + let state1 = DelayedRefreshState { + counting: counting1.clone(), + delay: Duration::from_millis(50), + }; + let (base_url, _) = start_axum_server(delayed_error_handler, state1).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("expired-token", 0, true), + )); + + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + + for result in &results { + assert!( + result.is_err(), + "first wave: expected Expired, got Ok({})", + result.as_ref().unwrap().as_str() + ); + } + + // Phase 2: New server that returns success. + let counting2 = CountingState::new(); + let state2 = DelayedRefreshState { + counting: counting2.clone(), + delay: Duration::from_millis(50), + }; + let (base_url2, stats2) = start_axum_server(delayed_refresh_handler, state2).await; + + let strategy2 = Arc::new(auto_refresh_with_token( + &dir, + &base_url2, + make_token("expired-token", 0, true), + )); + + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy2); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + + for token in &results { + assert_eq!( + token.as_str(), + "refreshed-token", + "retry callers should receive refreshed token" + ); + } + + assert_eq!(stats2.total(), 1, "only one retry refresh should be made"); + } + } + + mod given_cancelled_refresh { + use super::*; + + /// If a blocking refresh (fully expired token) is cancelled mid-flight, + /// the `CancelGuard` must reset `refresh_in_progress` and notify waiters + /// so the next caller doesn't hang in `wait_for_in_flight_refresh`. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn blocked_callers_recover_after_cancellation() { + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_secs(10), // Very slow — will be cancelled + }; + let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("expired-token", 0, true), + )); + + // Spawn get_token and let the blocking refresh start. + let s = Arc::clone(&strategy); + let handle = tokio::spawn(async move { s.get_token().await }); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Cancel the refresh mid-flight. + handle.abort(); + let _ = handle.await; + + // The next caller must not hang. The credential is lost (refresh + // token was taken before the HTTP call), so the result is Expired, + // but the important thing is that it completes promptly. + let s = Arc::clone(&strategy); + let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await; + + assert!( + result.is_ok(), + "get_token() should not hang after cancelled blocking refresh" + ); + } + + /// Regression test: cancellation in the window *after* the upstream + /// HTTP refresh succeeds but *before* the new token is installed must + /// still clear `refresh_in_progress` and notify waiters. The previous + /// implementation defused the [`CancelGuard`] before + /// `save_refreshed_token`, so a drop during the (async) store-save or + /// the subsequent state-lock acquire would strand the flag — wedging + /// any caller that later hit `wait_for_in_flight_refresh`. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn save_phase_cancellation_does_not_strand_in_progress_flag() { + use crate::token_store::TokenStore; + + /// Store that returns a single pre-loaded token from `load()` and + /// delays inside `save()` long enough for a test to cancel. + struct SlowSaveStore { + initial: tokio::sync::Mutex<Option<Token>>, + delay: Duration, + } + + impl TokenStore for SlowSaveStore { + async fn load(&self) -> Option<Token> { + self.initial.lock().await.take() + } + + async fn save(&self, _token: &Token) { + tokio::time::sleep(self.delay).await; + } + } + + // Fast upstream HTTP — refresh succeeds in <50ms. + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_millis(10), + }; + let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + let refresher = DeviceSessionRefresher::new( + Some(ws_store), + base_url, + "cli", + "ap-southeast-2.aws", + None, + crate::transport::default_transport(), + ); + // Slow async save — cancellation reliably lands here, in the + // post-HTTP / pre-install window. + let slow_store = SlowSaveStore { + initial: tokio::sync::Mutex::new(Some(make_token("expired-token", 0, true))), + delay: Duration::from_secs(10), + }; + let strategy = Arc::new(AutoRefresh::with_store(refresher, slow_store)); + + // Trigger refresh; the task will complete the HTTP exchange and + // then block inside store.save (the slow async path). + let s = Arc::clone(&strategy); + let handle = tokio::spawn(async move { s.get_token().await }); + // 200ms is comfortably past the 10ms HTTP delay but well inside + // the 10s save delay — so abort() lands during save_refreshed_token. + tokio::time::sleep(Duration::from_millis(200)).await; + handle.abort(); + let _ = handle.await; + + // The CancelGuard must have cleared refresh_in_progress on drop. + // If the old (pre-fix) code regresses, the flag stays true and a + // subsequent caller wedges on wait_for_in_flight_refresh waiting + // for a notify that will never come — the timeout below catches it. + let s = Arc::clone(&strategy); + let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await; + + assert!( + result.is_ok(), + "get_token() should not hang after cancellation in the save/install window" + ); + } + + /// If a non-blocking refresh (expiring-but-usable token) is cancelled + /// mid-flight, the `CancelGuard` must reset `refresh_in_progress` and + /// notify waiters so they don't hang once the token crosses real expiry. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn non_blocking_callers_recover_after_cancellation() { + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_secs(10), // Very slow — will be cancelled + }; + let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + // Token expires in 30s — is_expired() = true, is_usable() = true. + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("still-usable", 30, true), + )); + + // Spawn get_token — triggers non-blocking refresh, drops lock, then + // blocks on the slow HTTP call. + let s = Arc::clone(&strategy); + let handle = tokio::spawn(async move { s.get_token().await }); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Cancel the refresh mid-flight. + handle.abort(); + let _ = handle.await; + + // The next caller must not hang. The token is still usable so it + // should be returned even though the refresh was cancelled. + let s = Arc::clone(&strategy); + let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await; + + assert!( + result.is_ok(), + "get_token() should not hang after cancelled non-blocking refresh" + ); + let result = result.unwrap(); + assert!( + result.is_ok(), + "expected Ok with still-usable token, got: {:?}", + result.unwrap_err() + ); + } + } +} + +/// Deterministic regression test for the "token crosses real expiry while a +/// non-blocking refresh is in flight" race. +/// +/// Before the fix, late-arriving callers saw `refresh_in_progress = true` + +/// `!is_usable()` and returned `Err(Expired)` instead of waiting for the +/// in-flight refresh. The original reproduction (a wall-clock stress test) hung +/// the outcome on a ~1-second window — token expiry has whole-second +/// granularity — which made it latently flaky and broke outright under coverage +/// instrumentation. This version drives expiry with a [`TestClock`] and gates +/// the refresh with a [`Notify`], so it is fully deterministic: no real sleeps, +/// no network. +#[cfg(test)] +#[cfg(feature = "http")] +#[allow(clippy::unwrap_used)] +mod expiry_crossing_regression { + use super::*; + use crate::clock::TestClock; + use crate::{AuthError, SecretToken}; + use std::future::Future; + use std::sync::atomic::AtomicUsize; + use std::sync::Arc; + + /// Number of callers that arrive after the token crosses real expiry. + const WAITERS: usize = 8; + + /// A [`Refresher`] whose `refresh` blocks on a test-controlled gate, so the + /// test can hold a refresh "in flight" while it advances the clock and + /// launches waiters — with no wall-clock timing involved. + struct GatedRefresher { + /// Notified once `refresh` is entered (the refresh is now in flight). + started: Arc<Notify>, + /// `refresh` awaits this; the test releases it to complete the refresh. + gate: Arc<Notify>, + /// Counts `refresh` invocations — asserts exactly one refresh happens. + calls: Arc<AtomicUsize>, + /// Absolute expiry stamped on the refreshed token. + refreshed_expires_at: u64, + } + + impl Refresher for GatedRefresher { + type Credential = (); + + fn save(&self, _token: &Token) {} + + fn try_credential(&self, token: Option<&mut Token>) -> Option<Self::Credential> { + // Refresh only when there's a token to refresh, matching the real + // refreshers' "needs a prior token" contract. + token.map(|_| ()) + } + + fn restore(&self, _token: &mut Token, _credential: Self::Credential) {} + + fn refresh( + &self, + _credential: &Self::Credential, + ) -> impl Future<Output = Result<Token, AuthError>> + Send { + let started = Arc::clone(&self.started); + let gate = Arc::clone(&self.gate); + let calls = Arc::clone(&self.calls); + let refreshed_expires_at = self.refreshed_expires_at; + async move { + calls.fetch_add(1, Ordering::SeqCst); + started.notify_one(); + gate.notified().await; + Ok(make_token("refreshed-token", refreshed_expires_at)) + } + } + } + + fn make_token(access: &str, expires_at: u64) -> Token { + Token { + access_token: SecretToken::new(access), + refresh_token: Some(SecretToken::new("refresh-token")), + token_type: "Bearer".to_string(), + expires_at, + region: None, + client_id: None, + device_instance_id: None, + } + } + + #[tokio::test] + async fn waiters_wait_for_refresh_when_token_crosses_expiry() { + let clock = TestClock::new(1_000_000); + let started = Arc::new(Notify::new()); + let gate = Arc::new(Notify::new()); + let calls = Arc::new(AtomicUsize::new(0)); + + let refresher = GatedRefresher { + started: Arc::clone(&started), + gate: Arc::clone(&gate), + calls: Arc::clone(&calls), + refreshed_expires_at: clock.now() + 3600, + }; + + // Within the 90s leeway (so a refresh is triggered) but still usable at + // the current clock value (so the first caller takes the non-blocking + // path and gets the old token). + let token = make_token("expiring-soon", clock.now() + 10); + let strategy = Arc::new(AutoRefresh::with_token_and_clock( + refresher, + token, + clock.shared(), + )); + + // 1. First caller starts the non-blocking refresh. + let first = { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }; + // Wait until the refresh is actually in flight (gated; won't complete yet). + started.notified().await; + + // 2. Advance the clock past real expiry while the refresh is still gated. + // The token is now both expired and unusable. + clock.advance(20); + + // 3. Launch waiters. They observe refresh_in_progress + !is_usable, so they + // must wait for the in-flight refresh rather than returning Expired. + let waiters: Vec<_> = (0..WAITERS) + .map(|_| { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }) + .collect(); + + // Let the waiters reach their wait point. This is cooperative scheduling + // on the current-thread runtime (yielding lets the spawned waiters run + // until they park on the refresh notification), not a wall-clock delay. + for _ in 0..32 { + tokio::task::yield_now().await; + } + + // 4. Release the refresh. The first caller installs the new token and + // notifies the waiters, which then return the refreshed token. + gate.notify_one(); + + let first = first.await.unwrap().unwrap(); + assert_eq!( + first.as_str(), + "expiring-soon", + "first caller receives the old token (still usable when it was called)" + ); + + for (i, waiter) in waiters.into_iter().enumerate() { + let token = waiter.await.unwrap().unwrap_or_else(|e| { + panic!("waiter {i} returned Err({e:?}), expected the refreshed token") + }); + assert_eq!( + token.as_str(), + "refreshed-token", + "waiter {i} should receive the refreshed token, not Expired" + ); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "exactly one refresh should occur for all callers combined" + ); + } + + /// A [`Refresher`] like [`GatedRefresher`], but whose gated `refresh` + /// resolves to `Err` once released — so the test can exercise the *failure* + /// axis of the in-flight-refresh race. + struct FailingGatedRefresher { + started: Arc<Notify>, + gate: Arc<Notify>, + calls: Arc<AtomicUsize>, + /// Built per call rather than stored, because `AuthError` is not + /// `Clone`. Lets one refresher drive both the generic-failure and the + /// account-refusal axes, which take different paths out of the wait. + error: fn() -> AuthError, + } + + impl Refresher for FailingGatedRefresher { + type Credential = (); + + fn save(&self, _token: &Token) {} + + fn try_credential(&self, token: Option<&mut Token>) -> Option<Self::Credential> { + token.map(|_| ()) + } + + fn restore(&self, _token: &mut Token, _credential: Self::Credential) {} + + fn refresh( + &self, + _credential: &Self::Credential, + ) -> impl Future<Output = Result<Token, AuthError>> + Send { + let started = Arc::clone(&self.started); + let gate = Arc::clone(&self.gate); + let calls = Arc::clone(&self.calls); + let error = self.error; + async move { + calls.fetch_add(1, Ordering::SeqCst); + started.notify_one(); + gate.notified().await; + Err(error()) + } + } + } + + /// The failure counterpart to + /// [`waiters_wait_for_refresh_when_token_crosses_expiry`]: when the in-flight + /// refresh *fails* and the clock has crossed real expiry, waiters waking in + /// [`AutoRefresh::wait_for_in_flight_refresh`] must re-read the clock, find + /// the cached token unusable via `require_usable_token(now)`, and return + /// `Expired` — they must not hang, and must not hand back a stale token. This + /// is exactly the branch the post-wake clock re-read (the `now` re-read after + /// `notified().await`) exists to make correct. + #[tokio::test] + async fn waiters_get_expired_when_in_flight_refresh_fails() { + let clock = TestClock::new(1_000_000); + let started = Arc::new(Notify::new()); + let gate = Arc::new(Notify::new()); + let calls = Arc::new(AtomicUsize::new(0)); + + let refresher = FailingGatedRefresher { + started: Arc::clone(&started), + gate: Arc::clone(&gate), + calls: Arc::clone(&calls), + error: || AuthError::TokenExpired(crate::error::TokenExpired), + }; + + // Within the 90s leeway (triggers a refresh) but still usable now, so the + // first caller takes the non-blocking path and captures the old token. + let token = make_token("expiring-soon", clock.now() + 10); + let strategy = Arc::new(AutoRefresh::with_token_and_clock( + refresher, + token, + clock.shared(), + )); + + // 1. First caller starts the (gated) non-blocking refresh. + let first = { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }; + started.notified().await; + + // 2. Advance the clock past real expiry while the refresh is still gated. + // The cached token is now both expired and unusable. + clock.advance(20); + + // 3. Launch waiters. They observe refresh_in_progress + !is_usable, so + // they park in wait_for_in_flight_refresh rather than returning early. + let waiters: Vec<_> = (0..WAITERS) + .map(|_| { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }) + .collect(); + for _ in 0..32 { + tokio::task::yield_now().await; + } + + // 4. Release the refresh → it returns Err. The first caller still returns + // the old token it captured while it was usable; the waiters re-read + // the now-advanced clock, find the token unusable, and get Expired. + gate.notify_one(); + + let first = first.await.unwrap(); + assert_eq!( + first.unwrap().as_str(), + "expiring-soon", + "first caller keeps the old token it captured before the refresh failed" + ); + + for (i, waiter) in waiters.into_iter().enumerate() { + let result = waiter.await.unwrap(); + assert!( + matches!(result, Err(AutoRefreshError::Expired)), + "waiter {i} should get Expired after the failed refresh, got: {result:?}" + ); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "exactly one refresh attempt for all callers combined" + ); + } + + /// The taxonomy counterpart to + /// [`waiters_get_expired_when_in_flight_refresh_fails`]: when the in-flight + /// refresh fails with a refusal no retry can clear, every waiter must + /// receive *that* refusal rather than `Expired`. + /// + /// Only the caller that issued the request sees the server's answer + /// directly; a waiter can learn it solely from the recorded denial, which + /// `refresh_non_blocking` writes before `notify_waiters` wakes it. Without + /// that consultation the waiters get `Expired`, which `cipherstash-cli` + /// maps to `NoAuth` and turns into a login prompt — the one remedy + /// guaranteed not to clear a usage limit. + /// + /// The window is narrow but not exotic: it needs only a proactive refresh + /// that starts inside the expiry leeway and a token that crosses real + /// expiry before the request comes back. + #[tokio::test] + async fn waiters_get_the_refusal_when_the_in_flight_refresh_is_denied() { + let clock = TestClock::new(1_000_000); + let started = Arc::new(Notify::new()); + let gate = Arc::new(Notify::new()); + let calls = Arc::new(AtomicUsize::new(0)); + + let refresher = FailingGatedRefresher { + started: Arc::clone(&started), + gate: Arc::clone(&gate), + calls: Arc::clone(&calls), + error: || { + AuthError::UsageLimitExceeded(crate::error::UsageLimitExceeded( + "Workspace has exceeded its usage limit".to_string(), + )) + }, + }; + + // Inside the leeway (so a refresh starts) but still usable, so the + // first caller takes the non-blocking path. + let token = make_token("expiring-soon", clock.now() + 10); + let strategy = Arc::new(AutoRefresh::with_token_and_clock( + refresher, + token, + clock.shared(), + )); + + let first = { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }; + started.notified().await; + + // Cross real expiry while the refresh is gated, so the waiters park in + // `wait_for_in_flight_refresh` instead of being served the cached token. + clock.advance(20); + + let waiters: Vec<_> = (0..WAITERS) + .map(|_| { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }) + .collect(); + for _ in 0..32 { + tokio::task::yield_now().await; + } + + gate.notify_one(); + + let first = first.await.unwrap(); + assert_eq!( + first.unwrap().as_str(), + "expiring-soon", + "first caller keeps the old token it captured before the refusal" + ); + + for (i, waiter) in waiters.into_iter().enumerate() { + let result = waiter.await.unwrap(); + assert!( + matches!( + result, + Err(AutoRefreshError::Auth(AuthError::UsageLimitExceeded(_))) + ), + "waiter {i} must receive the usage refusal, not a generic \ + expiry, got: {result:?}" + ); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "the waiters must be served from the recorded denial, not by \ + re-issuing the request the refusal exists to suppress" + ); + } + + /// A [`Refresher`] whose `refresh` panics if it is ever called, so a test can + /// assert that no refresh is triggered. + struct NeverRefresher; + + impl Refresher for NeverRefresher { + type Credential = (); + + fn save(&self, _token: &Token) {} + + fn try_credential(&self, token: Option<&mut Token>) -> Option<Self::Credential> { + token.map(|_| ()) + } + + fn restore(&self, _token: &mut Token, _credential: Self::Credential) {} + + // Keep the explicit `impl Future + Send` form to match the sibling test + // refreshers; the trivial body would otherwise trip `manual_async_fn`. + #[allow(clippy::manual_async_fn)] + fn refresh( + &self, + _credential: &Self::Credential, + ) -> impl Future<Output = Result<Token, AuthError>> + Send { + async { panic!("refresh must not be called while the token reads as fresh") } + } + } + + /// The generalisation of `waiters_get_the_refusal_when_the_in_flight_refresh_is_denied`: + /// waiters must see the issuer's actual refusal even when it is *not* one + /// of the two account-level codes the sticky denial cache exists for. + /// `invalid_grant` is a settled, non-retryable answer (the refresh token + /// was rotated or revoked) — but because it isn't an account refusal, + /// `record_refusal` never caches it as a sticky `denial`, so a waiter + /// woken from `wait_for_in_flight_refresh` used to fall all the way + /// through to a generic `Expired`, hiding *why* the refresh failed from + /// every caller except the one that happened to perform it. + #[tokio::test] + async fn waiters_get_the_issuers_error_even_when_it_is_not_an_account_refusal() { + let clock = TestClock::new(1_000_000); + let started = Arc::new(Notify::new()); + let gate = Arc::new(Notify::new()); + let calls = Arc::new(AtomicUsize::new(0)); + + let refresher = FailingGatedRefresher { + started: Arc::clone(&started), + gate: Arc::clone(&gate), + calls: Arc::clone(&calls), + error: || AuthError::InvalidGrant(crate::error::InvalidGrant), + }; + + // Inside the leeway (so a refresh starts) but still usable, so the + // first caller takes the non-blocking path. + let token = make_token("expiring-soon", clock.now() + 10); + let strategy = Arc::new(AutoRefresh::with_token_and_clock( + refresher, + token, + clock.shared(), + )); + + let first = { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }; + started.notified().await; + + // Cross real expiry while the refresh is gated, so the waiters park in + // `wait_for_in_flight_refresh` instead of being served the cached token. + clock.advance(20); + + let waiters: Vec<_> = (0..WAITERS) + .map(|_| { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }) + .collect(); + for _ in 0..32 { + tokio::task::yield_now().await; + } + + gate.notify_one(); + + let first = first.await.unwrap(); + assert_eq!( + first.unwrap().as_str(), + "expiring-soon", + "first caller keeps the old token it captured before the refusal" + ); + + for (i, waiter) in waiters.into_iter().enumerate() { + let result = waiter.await.unwrap(); + assert!( + matches!( + result, + Err(AutoRefreshError::Auth(AuthError::InvalidGrant(_))) + ), + "waiter {i} must receive the invalid_grant refusal — a settled, \ + non-retryable answer — not a generic Expired that hides why the \ + refresh actually failed, got: {result:?}" + ); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "exactly one refresh attempt for all callers combined" + ); + } + + /// A wall clock running *backwards* (NTP step, VM snapshot restore) must not + /// panic or spuriously force a refresh. Each `get_token` call samples `now` + /// once and re-evaluates the pure `is_expired_at`/`is_usable_at` predicates, + /// and the only time subtraction in the crate (`Token::expires_in`) saturates + /// — so there is no cross-call delta to underflow. A rewind simply makes the + /// token read as fresh again. + #[tokio::test] + async fn backwards_clock_does_not_panic_or_force_refresh() { + let clock = TestClock::new(1_000_000); + // Fresh token: expires well beyond the 90s leeway. + let token = make_token("fresh", clock.now() + 3600); + let strategy = AutoRefresh::with_token_and_clock(NeverRefresher, token, clock.shared()); + + // Forward reading returns the cached token without refreshing. + assert_eq!(strategy.get_token().await.unwrap().as_str(), "fresh"); + + // The wall clock jumps 100_000s into the past. + clock.set(900_000); + + // Still fresh, still no refresh (NeverRefresher would panic), no hang. + assert_eq!(strategy.get_token().await.unwrap().as_str(), "fresh"); + } +} + +#[cfg(test)] +#[cfg(feature = "http")] +#[allow(clippy::unwrap_used)] +mod regression_cip_3159 { + use super::*; + use crate::access_key_refresher::AccessKeyRefresher; + use crate::SecretToken; + use std::sync::atomic::Ordering; + use std::sync::Arc; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + /// `/api/authorise` handler that sleeps `delay` before returning a valid + /// access-key token response (with an ABSOLUTE-epoch `expiry`, as CTS + /// returns), giving the test a window to cancel in. + async fn delayed_authorise_handler( + axum::extract::State(delay): axum::extract::State<Duration>, + ) -> axum::Json<serde_json::Value> { + tokio::time::sleep(delay).await; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + axum::Json(serde_json::json!({ + "accessToken": "refreshed-token", + "expiry": now + 3600 + })) + } + + async fn start_authorise_server(delay: Duration) -> url::Url { + let app = axum::Router::new() + .route( + "/api/authorise", + axum::routing::post(delayed_authorise_handler), + ) + .with_state(delay); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + url::Url::parse(&format!("http://{addr}")).unwrap() + } + + /// is_expired() == true (within the 90s leeway, so `get_token` refreshes), + /// but is_usable() == true for `secs_until_expiry` (so it takes the + /// non-blocking path). + fn expiring_but_usable_token(access: &str, secs_until_expiry: u64) -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + Token { + access_token: SecretToken::new(access), + token_type: "Bearer".to_string(), + expires_at: now + secs_until_expiry, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn cancellation_in_relock_window_does_not_strand_refresh() { + let http_delay = Duration::from_millis(400); + let base_url = start_authorise_server(http_delay).await; + + let strategy = Arc::new(AutoRefresh::with_token( + AccessKeyRefresher::new( + SecretToken::new("CSAKtestKeyId.testKeySecret"), + base_url, + None, + crate::transport::default_transport(), + ), + expiring_but_usable_token("old-usable", 2), + )); + + // Caller A drives the refresh: it locks state, sets the in-progress + // flag, drops the lock, then awaits the (slow) HTTP authorise call. + let a = Arc::clone(&strategy); + let handle = tokio::spawn(async move { a.get_token().await }); + + // Let A reach the HTTP await, then take the state lock so that when A's + // request completes it parks on its post-HTTP `state.lock().await` + // instead of installing the new token. + tokio::time::sleep(Duration::from_millis(100)).await; + let held = strategy.state.lock().await; + + // A's HTTP completes (~400ms) and blocks on the lock we hold. + tokio::time::sleep(http_delay + Duration::from_millis(200)).await; + assert!( + strategy.refresh_in_progress.load(Ordering::Acquire), + "precondition: a refresh should be in flight while caller A is parked", + ); + + // Cancel A precisely in the post-HTTP, pre-install window. + handle.abort(); + let _ = handle.await; + drop(held); + + // The CancelGuard's Drop must have cleared the flag on cancellation. + // Pre-fix, defuse() ran before the re-lock, so this stays `true`. + assert!( + !strategy.refresh_in_progress.load(Ordering::Acquire), + "refresh_in_progress stranded `true` after cancellation in the re-lock window (CIP-3159)", + ); + + // End-to-end: once the cached token crosses real expiry, a stranded flag + // would route the next caller into wait_for_in_flight_refresh and hang on + // a notify that never comes. With the fix, the caller re-authenticates. + tokio::time::sleep(Duration::from_millis(2100)).await; + let b = Arc::clone(&strategy); + let result = + tokio::time::timeout(Duration::from_secs(3), async move { b.get_token().await }).await; + assert!( + matches!(result, Ok(Ok(_))), + "get_token() hung or failed after cancellation — refresh wedged (CIP-3159): {result:?}", + ); + } +} diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs new file mode 100644 index 000000000..3aeba163b --- /dev/null +++ b/packages/stack-auth/src/auto_strategy.rs @@ -0,0 +1,452 @@ +use cts_common::Crn; + +use crate::access_key_strategy::AccessKeyStrategy; +use crate::device_session_strategy::DeviceSessionStrategy; +#[cfg(not(target_arch = "wasm32"))] +use stack_profile::ProfileStore; + +use crate::transport::{self, SharedTransport}; +use crate::HttpTransport; +#[cfg(not(target_arch = "wasm32"))] +use crate::Token; +use crate::{AuthError, AuthStrategy, ServiceToken}; + +/// An [`AuthStrategy`] that automatically detects available credentials +/// and delegates to the appropriate inner strategy. +/// +/// # Detection order +/// +/// 1. If the `CS_CLIENT_ACCESS_KEY` environment variable is set, an +/// [`AccessKeyStrategy`] is created. The workspace CRN is parsed from +/// `CS_WORKSPACE_CRN` (or the explicit +/// [`with_workspace_crn`](AutoStrategyBuilder::with_workspace_crn) value); +/// its region drives service discovery and its workspace ID is used +/// to verify every issued token. +/// 2. If a token store file exists at the default location +/// (`~/.cipherstash/auth.json`), a [`DeviceSessionStrategy`] is created from it. +/// 3. Otherwise, [`AuthError::NotAuthenticated`] is returned. +/// +/// # Examples +/// +/// ```no_run +/// use stack_auth::{AuthStrategy, AutoStrategy}; +/// +/// # async fn run() -> Result<(), Box<dyn std::error::Error>> { +/// // Auto-detect from env vars + profile store +/// let strategy = AutoStrategy::detect()?; +/// let token = (&strategy).get_token().await?; +/// println!("Authenticated! token={:?}", token); +/// # Ok(()) +/// # } +/// ``` +/// +/// ```no_run +/// use stack_auth::AutoStrategy; +/// +/// # fn run() -> Result<(), Box<dyn std::error::Error>> { +/// // Provide explicit values with env/profile fallback +/// let strategy = AutoStrategy::builder() +/// .with_access_key("CSAK...") +/// .detect()?; +/// # Ok(()) +/// # } +/// ``` +pub enum AutoStrategy { + /// Authenticated via a static access key. + AccessKey(AccessKeyStrategy), + /// Authenticated via OAuth tokens persisted on disk. + DeviceSession(DeviceSessionStrategy), +} + +impl AutoStrategy { + /// Create a builder for configuring credential resolution. + /// + /// The builder lets callers provide explicit values (access key, workspace CRN) + /// that take precedence over environment variables and the profile store. + /// + /// # Example + /// + /// ```no_run + /// use stack_auth::AutoStrategy; + /// use cts_common::Crn; + /// + /// # fn run() -> Result<(), Box<dyn std::error::Error>> { + /// let crn: Crn = "crn:ap-southeast-2.aws:workspace-id".parse()?; + /// let strategy = AutoStrategy::builder() + /// .with_access_key("CSAKmyKeyId.myKeySecret") + /// .with_workspace_crn(crn) + /// .detect()?; + /// # Ok(()) + /// # } + /// ``` + pub fn builder() -> AutoStrategyBuilder { + AutoStrategyBuilder { + access_key: None, + crn: None, + transport: None, + } + } + + /// Detect credentials from environment variables and profile store. + /// + /// Equivalent to `AutoStrategy::builder().detect()`. + /// + /// Resolution order: + /// 1. `CS_CLIENT_ACCESS_KEY` env var → [`AccessKeyStrategy`] + /// 2. `~/.cipherstash/auth.json` → [`DeviceSessionStrategy`] + /// 3. [`AuthError::NotAuthenticated`] + pub fn detect() -> Result<Self, AuthError> { + Self::builder().detect() + } + + /// Core detection logic, separated for testability. + /// + /// Takes pre-resolved inputs rather than reading from the environment + /// or filesystem directly. On wasm32 the profile-store fallback is + /// unreachable (no filesystem) — callers must supply an access key. + #[cfg(not(target_arch = "wasm32"))] + fn detect_inner( + access_key: Option<String>, + crn: Option<Crn>, + store: Option<ProfileStore>, + transport: Option<SharedTransport>, + ) -> Result<Self, AuthError> { + // 1. Access key from environment + if let Some(access_key) = access_key { + let workspace_crn = crn.ok_or(AuthError::MissingWorkspaceCrn( + crate::error::MissingWorkspaceCrn, + ))?; + let key: crate::AccessKey = access_key.parse()?; + let strategy = AccessKeyStrategy::builder(workspace_crn, key) + .maybe_transport(transport) + .build()?; + return Ok(Self::AccessKey(strategy)); + } + + // 2. OAuth token from disk (in the current workspace directory) + if let Some(store) = store { + let has_token = store + .current_workspace_store() + .map(|ws| ws.exists_profile::<Token>()) + .unwrap_or(false); + if has_token { + let strategy = DeviceSessionStrategy::with_profile(store) + .maybe_transport(transport) + .build()?; + return Ok(Self::DeviceSession(strategy)); + } + } + + // 3. No credentials found + Err(AuthError::NotAuthenticated(crate::error::NotAuthenticated)) + } + + #[cfg(target_arch = "wasm32")] + fn detect_inner( + access_key: Option<String>, + crn: Option<Crn>, + transport: Option<SharedTransport>, + ) -> Result<Self, AuthError> { + if let Some(access_key) = access_key { + let workspace_crn = crn.ok_or(AuthError::MissingWorkspaceCrn( + crate::error::MissingWorkspaceCrn, + ))?; + let key: crate::AccessKey = access_key.parse()?; + let strategy = AccessKeyStrategy::builder(workspace_crn, key) + .maybe_transport(transport) + .build()?; + return Ok(Self::AccessKey(strategy)); + } + Err(AuthError::NotAuthenticated(crate::error::NotAuthenticated)) + } +} + +/// Builder for configuring credential resolution before calling [`detect()`](AutoStrategyBuilder::detect). +/// +/// Explicit values provided via builder methods take precedence over environment variables. +/// Environment variables take precedence over the profile store. +/// +/// # Example +/// +/// ```no_run +/// use stack_auth::AutoStrategy; +/// +/// # fn run() -> Result<(), Box<dyn std::error::Error>> { +/// // Provide access key explicitly, region from CS_WORKSPACE_CRN env var +/// let strategy = AutoStrategy::builder() +/// .with_access_key("CSAKmyKeyId.myKeySecret") +/// .detect()?; +/// # Ok(()) +/// # } +/// ``` +pub struct AutoStrategyBuilder { + access_key: Option<String>, + crn: Option<Crn>, + transport: Option<SharedTransport>, +} + +impl AutoStrategyBuilder { + /// Send the detected strategy's requests through `transport` instead of + /// the bundled `reqwest` client. Required without the `http` feature. + pub fn transport(mut self, transport: impl HttpTransport) -> Self { + self.transport = Some(transport::share(transport)); + self + } + + /// Provide an explicit access key. Takes precedence over env vars. + pub fn with_access_key(mut self, access_key: impl Into<String>) -> Self { + self.access_key = Some(access_key.into()); + self + } + + /// Provide an explicit workspace CRN. Takes precedence over env vars. + pub fn with_workspace_crn(mut self, crn: Crn) -> Self { + self.crn = Some(crn); + self + } + + /// Resolve the auth strategy. + /// + /// Resolution order: + /// 1. Explicit values provided via builder methods + /// 2. Environment variables (`CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN`) + /// 3. Profile store (`~/.cipherstash/auth.json` for OAuth) + /// 4. [`AuthError::NotAuthenticated`] + pub fn detect(self) -> Result<AutoStrategy, AuthError> { + // Merge explicit values with env vars (explicit wins) + let access_key = self + .access_key + .or_else(|| std::env::var("CS_CLIENT_ACCESS_KEY").ok()); + + let crn = match self.crn { + Some(crn) => Some(crn), + None => std::env::var("CS_WORKSPACE_CRN") + .ok() + .map(|s| s.parse::<Crn>().map_err(AuthError::from)) + .transpose()?, + }; + + #[cfg(not(target_arch = "wasm32"))] + { + // Resolve errors (e.g. missing profile directory) are intentionally + // swallowed here so that env-var-only setups don't need a profile dir. + // If no credentials are found at all, NotAuthenticated is returned. + let store = match ProfileStore::resolve(None) { + Ok(s) => Some(s), + Err(e) => { + tracing::info!(error = %e, "could not resolve profile store; continuing without it"); + None + } + }; + AutoStrategy::detect_inner(access_key, crn, store, self.transport) + } + #[cfg(target_arch = "wasm32")] + { + AutoStrategy::detect_inner(access_key, crn, self.transport) + } + } +} + +impl AuthStrategy for &AutoStrategy { + async fn get_token(self) -> Result<ServiceToken, AuthError> { + match self { + AutoStrategy::AccessKey(inner) => inner.get_token().await, + AutoStrategy::DeviceSession(inner) => inner.get_token().await, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(feature = "http")] + use crate::{SecretToken, Token}; + #[cfg(feature = "http")] + use std::time::{SystemTime, UNIX_EPOCH}; + + const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; + + fn valid_crn() -> Crn { + VALID_CRN.parse().unwrap() + } + + #[cfg(feature = "http")] // only the strategy-building tests use it + fn make_oauth_token() -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-user", + "aud": "test-audience", + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "org_id": "org_test_default", + "scope": "", + }); + + let key = jsonwebtoken::EncodingKey::from_secret(b"test-secret"); + let jwt = jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &key).unwrap(); + + Token { + access_token: SecretToken::new(jwt), + token_type: "Bearer".to_string(), + expires_at: now + 3600, + refresh_token: Some(SecretToken::new("test-refresh-token")), + region: Some("ap-southeast-2.aws".to_string()), + client_id: Some("test-client-id".to_string()), + device_instance_id: None, + } + } + + #[cfg(feature = "http")] // only the strategy-building tests use it + fn write_token_store(dir: &std::path::Path) -> ProfileStore { + let store = ProfileStore::new(dir); + store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store.save_profile(&make_oauth_token()).unwrap(); + store + } + + mod detect_inner { + use super::*; + + #[test] + #[cfg(feature = "http")] // builds a strategy with no transport of its own + fn access_key_with_valid_crn() { + let result = AutoStrategy::detect_inner( + Some("CSAKtestKeyId.testKeySecret".into()), + Some(valid_crn()), + None, + None, + ); + + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); + } + + #[test] + fn access_key_without_crn_returns_missing_workspace_crn() { + let result = AutoStrategy::detect_inner( + Some("CSAKtestKeyId.testKeySecret".into()), + None, + None, + None, + ); + + assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn(_)))); + } + + #[test] + fn invalid_access_key_format_returns_invalid_access_key() { + let result = AutoStrategy::detect_inner( + Some("not-a-valid-key".into()), + Some(valid_crn()), + None, + None, + ); + + assert!(matches!(result, Err(AuthError::InvalidAccessKey(_)))); + } + + #[test] + #[cfg(feature = "http")] // builds a strategy with no transport of its own + fn oauth_store_with_valid_token() { + let dir = tempfile::tempdir().unwrap(); + let store = write_token_store(dir.path()); + + let result = AutoStrategy::detect_inner(None, None, Some(store), None); + + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), AutoStrategy::DeviceSession(_))); + } + + #[test] + fn oauth_store_without_token_file_returns_not_authenticated() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let result = AutoStrategy::detect_inner(None, None, Some(store), None); + + assert!(matches!(result, Err(AuthError::NotAuthenticated(_)))); + } + + #[test] + fn no_credentials_returns_not_authenticated() { + let result = AutoStrategy::detect_inner(None, None, None, None); + + assert!(matches!(result, Err(AuthError::NotAuthenticated(_)))); + } + + #[test] + #[cfg(feature = "http")] // builds a strategy with no transport of its own + fn access_key_takes_priority_over_oauth_store() { + let dir = tempfile::tempdir().unwrap(); + let store = write_token_store(dir.path()); + + let result = AutoStrategy::detect_inner( + Some("CSAKtestKeyId.testKeySecret".into()), + Some(valid_crn()), + Some(store), + None, + ); + + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); + } + } + + mod builder { + use super::*; + + #[test] + #[cfg(feature = "http")] // builds a strategy with no transport of its own + fn explicit_access_key_and_crn() { + let result = AutoStrategy::builder() + .with_access_key("CSAKtestKeyId.testKeySecret") + .with_workspace_crn(valid_crn()) + .detect(); + + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); + } + + // Both tests below set process environment. `temp_env` serialises + // them (and restores the variable afterwards), which matters under + // plain `cargo test`, where tests share one process. + #[test] + fn explicit_access_key_without_crn_and_no_env_returns_missing_workspace_crn() { + let result = temp_env::with_var_unset("CS_WORKSPACE_CRN", || { + AutoStrategy::builder() + .with_access_key("CSAKtestKeyId.testKeySecret") + .detect() + }); + + assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn(_)))); + } + + #[test] + fn invalid_crn_env_var_returns_invalid_crn() { + let result = temp_env::with_var("CS_WORKSPACE_CRN", Some("not-a-crn"), || { + AutoStrategy::builder() + .with_access_key("CSAKtestKeyId.testKeySecret") + .detect() + }); + + assert!(matches!(result, Err(AuthError::InvalidCrn(_)))); + } + + #[test] + fn invalid_explicit_access_key_returns_invalid_access_key() { + let result = AutoStrategy::builder() + .with_access_key("not-a-valid-key") + .with_workspace_crn(valid_crn()) + .detect(); + + assert!(matches!(result, Err(AuthError::InvalidAccessKey(_)))); + } + } +} diff --git a/packages/stack-auth/src/clock.rs b/packages/stack-auth/src/clock.rs new file mode 100644 index 000000000..9eb72001b --- /dev/null +++ b/packages/stack-auth/src/clock.rs @@ -0,0 +1,102 @@ +//! A small abstraction over "the current Unix time", in whole seconds. +//! +//! Token expiry ([`Token::is_expired`](crate::Token::is_expired), +//! [`Token::is_usable`](crate::Token::is_usable)) is time-dependent. In +//! production that time comes from the system wall clock, but a wall clock makes +//! the refresh concurrency tests inherently racy — the token's usable window is +//! measured in whole seconds, so test setup (or coverage instrumentation) can +//! cross an expiry boundary before the assertions run. +//! +//! [`Clock`] lets [`AutoRefresh`](crate::auto_refresh::AutoRefresh) read "now" +//! from an injected source: [`SystemClock`] in production, a controllable clock +//! in tests. + +// `Arc` only backs the shared handles (`SharedClock`, `TestClock`); the bare +// `Clock`/`SystemClock` used by `Token` expiry need no sharing. +use std::sync::Arc; + +use web_time::{SystemTime, UNIX_EPOCH}; + +/// Source of the current time as seconds since the Unix epoch. +pub(crate) trait Clock: Send + Sync { + /// The current time, in whole seconds since the Unix epoch. + fn now_unix_secs(&self) -> u64; +} + +/// A shared, type-erased [`Clock`] handle. +/// +/// Type-erased (rather than a generic parameter on `AutoRefresh`) so injecting a +/// clock doesn't ripple a third generic through every strategy wrapper. +pub(crate) type SharedClock = Arc<dyn Clock>; + +/// The default [`Clock`]: the system wall clock. +/// +/// Uses `web_time` rather than `std::time` so it behaves correctly on `wasm32`, +/// matching the rest of the crate's time handling. +pub(crate) struct SystemClock; + +impl Clock for SystemClock { + fn now_unix_secs(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } +} + +/// The default shared clock (the system wall clock). +/// +/// Returns clones of one process-wide handle: `SystemClock` is a stateless ZST, +/// so there's no reason to allocate a fresh `Arc` per `AutoRefresh`. +pub(crate) fn system_clock() -> SharedClock { + static CLOCK: std::sync::LazyLock<SharedClock> = + std::sync::LazyLock::new(|| Arc::new(SystemClock)); + CLOCK.clone() +} + +/// A [`Clock`] whose value is set explicitly by the test, so token expiry can be +/// driven deterministically rather than racing the wall clock. +#[cfg(test)] +#[cfg(feature = "http")] +#[derive(Clone)] +pub(crate) struct TestClock(Arc<std::sync::atomic::AtomicU64>); + +#[cfg(test)] +#[cfg(feature = "http")] +impl TestClock { + /// Create a clock reading `now` seconds. + pub(crate) fn new(now: u64) -> Self { + Self(Arc::new(std::sync::atomic::AtomicU64::new(now))) + } + + /// Move the clock forward by `secs` seconds. + pub(crate) fn advance(&self, secs: u64) { + self.0.fetch_add(secs, std::sync::atomic::Ordering::SeqCst); + } + + /// Set the clock to an arbitrary value, including one earlier than the + /// current reading — used to simulate the wall clock jumping backwards + /// (NTP step, VM snapshot restore, manual clock change). + pub(crate) fn set(&self, now: u64) { + self.0.store(now, std::sync::atomic::Ordering::SeqCst); + } + + /// The current value of the clock. + pub(crate) fn now(&self) -> u64 { + self.0.load(std::sync::atomic::Ordering::SeqCst) + } + + /// A [`SharedClock`] handle backed by this same underlying value, so the test + /// and the `AutoRefresh` under test observe identical time. + pub(crate) fn shared(&self) -> SharedClock { + Arc::new(self.clone()) + } +} + +#[cfg(test)] +#[cfg(feature = "http")] +impl Clock for TestClock { + fn now_unix_secs(&self) -> u64 { + self.now() + } +} diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs new file mode 100644 index 000000000..6a1d22a0c --- /dev/null +++ b/packages/stack-auth/src/device_client.rs @@ -0,0 +1,331 @@ +//! Post-login device client provisioning. +//! +//! After a device-code login, the caller must create a client in ZeroKMS and +//! persist the resulting secret key to disk. This module provides the +//! orchestration logic so that any consumer (not just the CLI) can perform +//! this step. + +use stack_profile::{DeviceIdentity, ProfileStore}; +use uuid::Uuid; +use zerokms_protocol::{CreateClientRequest, CreateClientResponse, ViturKeyMaterial, ViturRequest}; + +use crate::error::RequestError; +use crate::transport::{self, ReqwestTransport}; +use crate::{ensure_trailing_slash, ServiceToken, Token}; + +// --------------------------------------------------------------------------- +// Secret key file (output) +// --------------------------------------------------------------------------- + +const SECRET_KEY_FILENAME: &str = "secretkey.json"; +const SECRET_KEY_MODE: u32 = 0o600; + +/// The on-disk shape of `secretkey.json`. +/// +/// Must stay in sync with `cipherstash_client::zerokms::SecretKey` which +/// deserializes this file. If that type moves to a shared crate, replace +/// this with a re-export. +#[derive(serde::Serialize)] +struct SecretKeyFile { + client_id: Uuid, + client_key: ViturKeyMaterial, +} + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +/// Errors that can occur during device client provisioning. +#[derive(Debug, thiserror::Error)] +pub enum DeviceClientError { + /// The profile store could not load or create required data. + #[error("Profile error: {0}")] + Profile(#[from] stack_profile::ProfileError), + + /// Authentication token could not be loaded or decoded. + #[error("Auth error: {0}")] + Auth(#[from] crate::AuthError), + + /// The HTTP request to ZeroKMS failed, or its response did not decode. + #[error("ZeroKMS request failed: {0}")] + Request(#[from] RequestError), + + /// ZeroKMS returned a non-success, non-conflict status. + #[error("ZeroKMS returned {status}: {body}")] + Server { status: u16, body: String }, + + /// Failed to construct the ZeroKMS endpoint URL. + #[error("Invalid ZeroKMS URL: {0}")] + InvalidUrl(#[from] url::ParseError), +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Provision a device client after login. +/// +/// Loads the auth token and device identity from disk, creates a client in +/// ZeroKMS (on the workspace's default keyset), and persists the resulting +/// secret key to the profile store. +/// +/// If the secret key already exists on disk, or the server returns 409 +/// (conflict), this is a no-op. +pub async fn bind_client_device(store: &ProfileStore) -> Result<(), DeviceClientError> { + let ws_store = store.current_workspace_store()?; + + if ws_store.exists(SECRET_KEY_FILENAME) { + tracing::debug!("secret key already exists, skipping provisioning"); + return Ok(()); + } + + let token: Token = ws_store.load_profile()?; + let service_token = ServiceToken::new(token.access_token().clone()); + let zerokms_url = ensure_trailing_slash(service_token.zerokms_url()?); + + // DeviceIdentity is NOT workspace-scoped, so this reads from the root. + let identity = DeviceIdentity::load_or_create(store)?; + + let request = CreateClientRequest { + keyset_id: None, + name: (&identity.device_name).into(), + description: (&identity.device_name).into(), + }; + + let url = zerokms_url.join(CreateClientRequest::ENDPOINT)?; + + // Provisioning is native-only, so it always uses the bundled transport. + let body = zeroize::Zeroizing::new( + serde_json::to_vec(&request).map_err(|e| RequestError(Box::new(e)))?, + ); + let response = transport::post( + &transport::share(ReqwestTransport::default()), + url, + "application/json", + // The shared transport adds `content-type` and the crate's + // `user-agent`: ZeroKMS sees which client build provisioned the device. + vec![( + "authorization".to_string(), + format!("Bearer {}", service_token.as_str()), + )], + body, + ) + .await?; + + let status = response.status(); + + if status == 409 { + // Another client was already provisioned server-side. + tracing::debug!("device client already exists, skipping"); + return Ok(()); + } + + if !response.is_success() { + return Err(DeviceClientError::Server { + status, + body: response.text(), + }); + } + + let created: CreateClientResponse = response.json()?; + + let secret_key = SecretKeyFile { + client_id: created.id, + client_key: created.client_key, + }; + + ws_store.save_with_mode(SECRET_KEY_FILENAME, &secret_key, SECRET_KEY_MODE)?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::SecretToken; + use mocktail::prelude::*; + use tempfile::TempDir; + + fn make_test_jwt(zerokms_url: impl std::fmt::Display) -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let zerokms_url = zerokms_url.to_string(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-user", + "aud": "legacy-aud-value", + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "org_id": "org_test_default", + "scope": "", + "services": { + "zerokms": zerokms_url, + }, + }); + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .unwrap() + } + + const TEST_WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY"; + + fn save_test_token(store: &ProfileStore, access_token: &str) { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let token = Token { + access_token: SecretToken::new(access_token), + refresh_token: None, + token_type: "Bearer".into(), + expires_at: now + 3600, + region: None, + client_id: None, + device_instance_id: None, + }; + store.init_workspace(TEST_WORKSPACE_ID).unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store.save_profile(&token).unwrap(); + } + + fn client_response_json() -> serde_json::Value { + serde_json::json!({ + "id": "00000000-0000-0000-0000-000000000001", + "dataset_id": "00000000-0000-0000-0000-000000000099", + "name": "test-device", + "description": "test-device", + "client_key": "dGVzdC1rZXktbWF0ZXJpYWw=" + }) + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("device-client-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + #[tokio::test] + async fn provisions_and_saves_secret_key() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + + // ZeroKMS sees which client build provisioned the device: the mock + // only answers a request that names this crate, version and platform. + let user_agent = format!( + "stack-auth/{} ({} {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + ); + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post() + .path("/create-client") + .header("user-agent", user_agent); + then.json(client_response_json()); + }); + let server = start_server(mocks).await; + + let jwt = make_test_jwt(server.url("/")); + save_test_token(&store, &jwt); + + bind_client_device(&store).await.unwrap(); + + let ws_store = store.workspace_store(TEST_WORKSPACE_ID).unwrap(); + let saved: serde_json::Value = ws_store.load(SECRET_KEY_FILENAME).unwrap(); + assert_eq!(saved["client_id"], "00000000-0000-0000-0000-000000000001"); + assert_eq!(saved["client_key"], "dGVzdC1rZXktbWF0ZXJpYWw="); + } + + #[tokio::test] + async fn skips_when_secret_key_exists() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + store.init_workspace(TEST_WORKSPACE_ID).unwrap(); + + // Pre-populate secretkey.json in the workspace directory + let ws_store = store.workspace_store(TEST_WORKSPACE_ID).unwrap(); + ws_store + .save_with_mode( + SECRET_KEY_FILENAME, + &serde_json::json!({"client_id": "old", "client_key": "old"}), + SECRET_KEY_MODE, + ) + .unwrap(); + + // No mock server needed — the HTTP call should never happen. + bind_client_device(&store).await.unwrap(); + + let saved: serde_json::Value = ws_store.load(SECRET_KEY_FILENAME).unwrap(); + assert_eq!( + saved["client_id"], "old", + "should not overwrite existing key" + ); + } + + #[tokio::test] + async fn no_op_on_conflict() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/create-client"); + then.status(reqwest::StatusCode::CONFLICT) + .json(serde_json::json!({"error": "conflict"})); + }); + let server = start_server(mocks).await; + + let jwt = make_test_jwt(server.url("/")); + save_test_token(&store, &jwt); + + bind_client_device(&store).await.unwrap(); + + let ws_store = store.workspace_store(TEST_WORKSPACE_ID).unwrap(); + assert!( + !ws_store.exists(SECRET_KEY_FILENAME), + "should not write secret key on conflict" + ); + } + + #[tokio::test] + async fn returns_error_on_server_failure() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/create-client"); + then.status(reqwest::StatusCode::INTERNAL_SERVER_ERROR) + .json(serde_json::json!({"error": "internal error"})); + }); + let server = start_server(mocks).await; + + let jwt = make_test_jwt(server.url("/")); + save_test_token(&store, &jwt); + + let err = bind_client_device(&store).await.unwrap_err(); + assert!( + matches!(err, DeviceClientError::Server { status: 500, .. }), + "expected Server error, got: {err:?}" + ); + } +} diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs new file mode 100644 index 000000000..1dabc932a --- /dev/null +++ b/packages/stack-auth/src/device_code/mod.rs @@ -0,0 +1,424 @@ +mod protocol; + +use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; +use url::Url; + +use std::time::{SystemTime, UNIX_EPOCH}; + +use std::path::PathBuf; + +use stack_profile::ProfileStore; + +use crate::transport::{self, ReqwestTransport, SharedTransport}; +use crate::{ensure_trailing_slash, AuthError, DeviceIdentity, Token}; +use protocol::{ + DeviceCode, DeviceCodeRequest, DeviceCodeResponse, ErrorResponse, TokenRequest, TokenResponse, +}; + +#[cfg(test)] +mod tests; + +// Keep the browser boundary visible to callers of `open_in_browser`. +fn launch_browser(uri: &str) -> std::io::Result<()> { + #[cfg(test)] + { + tests::browser::launch(uri) + } + + #[cfg(not(test))] + { + open::that(uri) + } +} + +/// The device-code flow is interactive and native-only, so it always runs +/// over the bundled transport. +fn bundled_transport() -> SharedTransport { + transport::share(ReqwestTransport::default()) +} + +/// Authenticates with CipherStash using the +/// [device code flow (RFC 8628)](https://datatracker.ietf.org/doc/html/rfc8628). +/// +/// This is the primary entry point for CLI and browserless authentication. +/// Create a strategy with [`DeviceCodeStrategy::new`], then call +/// [`begin`](DeviceCodeStrategy::begin) to start the flow. +/// +/// # Example +/// +/// ``` +/// use stack_auth::DeviceCodeStrategy; +/// use cts_common::Region; +/// +/// let region = Region::aws("ap-southeast-2").unwrap(); +/// let strategy = DeviceCodeStrategy::new(region, "my-client-id").unwrap(); +/// ``` +pub struct DeviceCodeStrategy { + region: Region, + base_url: Url, + client_id: String, + profile_dir: Option<PathBuf>, + device_identity: Option<DeviceIdentity>, +} + +impl DeviceCodeStrategy { + /// Create a new strategy for the given CipherStash region and OAuth client ID. + /// + /// The auth endpoint is resolved automatically via service discovery. + /// + /// # Example + /// + /// ``` + /// use stack_auth::DeviceCodeStrategy; + /// use cts_common::Region; + /// + /// let strategy = DeviceCodeStrategy::new( + /// Region::aws("ap-southeast-2").unwrap(), + /// "my-client-id", + /// ).unwrap(); + /// ``` + pub fn new(region: Region, client_id: impl Into<String>) -> Result<Self, AuthError> { + Self::builder(region, client_id).build() + } + + /// Return a builder for configuring a `DeviceCodeStrategy` before construction. + pub fn builder(region: Region, client_id: impl Into<String>) -> DeviceCodeStrategyBuilder { + DeviceCodeStrategyBuilder { + region, + client_id: client_id.into(), + base_url_override: None, + profile_dir: None, + device_identity: None, + } + } + + /// Start the device code flow. + /// + /// Requests a device code from the CipherStash auth server and returns a + /// [`PendingDeviceCode`] with the user-facing codes and URIs. Show these + /// to the user, then call [`PendingDeviceCode::poll_for_token`] to wait + /// for authorization. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidClient`] if the client ID is not recognized, + /// or [`AuthError::Request`] if the server is unreachable. + pub async fn begin(&self) -> Result<PendingDeviceCode, AuthError> { + let transport = bundled_transport(); + + let code_url = self.base_url.join("oauth/device/code")?; + + tracing::debug!(url = %code_url, client_id = %self.client_id, "requesting device code"); + + let device_instance_id = self + .device_identity + .as_ref() + .map(|d| d.device_instance_id.to_string()); + + let code_resp = transport::post_form( + &transport, + code_url, + &DeviceCodeRequest { + client_id: &self.client_id, + device_instance_id: device_instance_id.as_deref(), + device_name: self + .device_identity + .as_ref() + .map(|d| d.device_name.as_str()), + }, + ) + .await?; + + if !code_resp.is_success() { + let err: ErrorResponse = code_resp.json()?; + tracing::debug!(error = %err.error, "device code request failed"); + return Err(match err.error.as_str() { + "invalid_client" => AuthError::InvalidClient(crate::error::InvalidClient), + _ => AuthError::Server(crate::error::ServerError(err.error_description)), + }); + } + + let code: DeviceCodeResponse = code_resp.json()?; + + let token_url = self.base_url.join("oauth/device/token")?; + + tracing::debug!( + user_code = %code.user_code, + expires_in = code.expires_in, + "device code received" + ); + + Ok(PendingDeviceCode { + token_url, + region: self.region, + client_id: self.client_id.clone(), + device_code: code.device_code, + user_code: code.user_code, + verification_uri: code.verification_uri, + verification_uri_complete: code.verification_uri_complete, + expires_in: code.expires_in, + profile_dir: self.profile_dir.clone(), + device_identity: self.device_identity.clone(), + }) + } +} + +/// Builder for [`DeviceCodeStrategy`]. +/// +/// Created via [`DeviceCodeStrategy::builder`]. +pub struct DeviceCodeStrategyBuilder { + region: Region, + client_id: String, + base_url_override: Option<Url>, + profile_dir: Option<PathBuf>, + device_identity: Option<DeviceIdentity>, +} + +impl DeviceCodeStrategyBuilder { + /// Override the auth-server base URL resolved for this flow. + /// + /// Takes precedence over both the `CS_CTS_HOST` environment variable and + /// region-derived service discovery. Use it to point a single flow at a + /// specific host — e.g. a self-hosted CTS, or a local mock auth server in + /// development — without relying on the process-wide `CS_CTS_HOST`, which + /// would redirect every other CTS client sharing the process. + pub fn base_url(mut self, url: Url) -> Self { + self.base_url_override = Some(url); + self + } + + /// Override the profile directory used to persist the token. + /// + /// By default tokens are saved to `~/.cipherstash/auth.json`. Use this in + /// tests to redirect writes to a temporary directory. + #[cfg(any(test, feature = "test-utils"))] + pub fn profile_dir(mut self, dir: impl Into<PathBuf>) -> Self { + self.profile_dir = Some(dir.into()); + self + } + + /// Set the device identity for this strategy. + /// + /// When set, the device instance ID and name are sent to the auth server + /// during the device code flow and persisted in the token. + pub fn device_identity(mut self, identity: DeviceIdentity) -> Self { + self.device_identity = Some(identity); + self + } + + /// Build the [`DeviceCodeStrategy`]. + /// + /// Resolves the base URL in priority order: an explicit [`base_url`] + /// override, then the `CS_CTS_HOST` environment variable, then service + /// discovery using the region. + /// + /// [`base_url`]: Self::base_url + pub fn build(self) -> Result<DeviceCodeStrategy, AuthError> { + let base_url = match self.base_url_override { + Some(url) => url, + None => crate::cts_base_url_from_env()? + .unwrap_or(CtsServiceDiscovery::endpoint(self.region)?), + }; + Ok(DeviceCodeStrategy { + region: self.region, + base_url: ensure_trailing_slash(base_url), + client_id: self.client_id, + profile_dir: self.profile_dir, + device_identity: self.device_identity, + }) + } +} + +/// A device code flow that is waiting for the user to authorize. +/// +/// Returned by [`DeviceCodeStrategy::begin`]. Display the +/// [`user_code`](Self::user_code) and +/// [`verification_uri_complete`](Self::verification_uri_complete) to the user +/// (or call [`open_in_browser`](Self::open_in_browser)), then call +/// [`poll_for_token`](Self::poll_for_token) to wait for authorization. +/// +/// # Example +/// +/// ```no_run +/// # use stack_auth::DeviceCodeStrategy; +/// # use cts_common::Region; +/// # async fn run() -> Result<(), Box<dyn std::error::Error>> { +/// # let strategy = DeviceCodeStrategy::new(Region::aws("ap-southeast-2")?, "cli")?; +/// let pending = strategy.begin().await?; +/// +/// println!("Go to: {}", pending.verification_uri_complete()); +/// println!("Enter code: {}", pending.user_code()); +/// +/// let token = pending.poll_for_token().await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct PendingDeviceCode { + token_url: Url, + region: Region, + client_id: String, + device_code: DeviceCode, + /// The short code the user must enter to authorize this device. + user_code: String, + /// The base verification URI (without the user code embedded). + verification_uri: String, + /// The full verification URI with the user code pre-filled. + verification_uri_complete: String, + /// How many seconds the device code remains valid. + expires_in: u64, + /// Profile directory override. Falls back to `~/.cipherstash`. + profile_dir: Option<PathBuf>, + /// Device identity to associate with the token. + device_identity: Option<DeviceIdentity>, +} + +impl PendingDeviceCode { + /// The short code the user must enter to authorize this device. + pub fn user_code(&self) -> &str { + &self.user_code + } + + /// The base verification URI (without the user code embedded). + pub fn verification_uri(&self) -> &str { + &self.verification_uri + } + + /// The full verification URI with the user code pre-filled. + pub fn verification_uri_complete(&self) -> &str { + &self.verification_uri_complete + } + + /// How many seconds the device code remains valid. + pub fn expires_in(&self) -> u64 { + self.expires_in + } + + /// Open the verification URI in the user's default browser. + /// + /// Returns `true` if the browser was opened successfully. + pub fn open_in_browser(&self) -> bool { + launch_browser(&self.verification_uri_complete).is_ok() + } + + /// Poll the auth server until the user authorizes (or the code expires). + /// + /// This method consumes `self` and blocks asynchronously, polling at a + /// server-controlled interval (starting at 5 seconds). It returns a + /// [`Token`] on success. + /// + /// # Errors + /// + /// - [`AuthError::AccessDenied`] — the user rejected the request. + /// - [`AuthError::TokenExpired`] — the device code expired before the user + /// authorized. + /// - [`AuthError::Request`] — a network error occurred while polling. + pub async fn poll_for_token(self) -> Result<Token, AuthError> { + let transport = bundled_transport(); + let mut interval = tokio::time::Duration::from_secs(5); + let deadline = + tokio::time::Instant::now() + tokio::time::Duration::from_secs(self.expires_in); + + tracing::debug!( + url = %self.token_url, + expires_in = self.expires_in, + "polling for token" + ); + + loop { + if tokio::time::Instant::now() >= deadline { + tracing::debug!("device code expired while polling"); + return Err(AuthError::TokenExpired(crate::error::TokenExpired)); + } + + let resp = transport::post_form( + &transport, + self.token_url.clone(), + &TokenRequest { + client_id: &self.client_id, + device_code: &self.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }, + ) + .await?; + + if resp.is_success() { + tracing::debug!("token received"); + let token_resp: TokenResponse = resp.json()?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut token = Token { + access_token: token_resp.access_token, + token_type: token_resp.token_type, + expires_at: now + token_resp.expires_in, + refresh_token: token_resp.refresh_token, + region: None, + client_id: None, + device_instance_id: None, + }; + token.set_region(self.region.identifier()); + token.set_client_id(&self.client_id); + if let Some(ref identity) = self.device_identity { + token.set_device_instance_id(identity.device_instance_id.to_string()); + } + + let store = match &self.profile_dir { + Some(dir) => ProfileStore::new(dir), + None => ProfileStore::resolve(None)?, + }; + let workspace_id = token.workspace_id()?; + store.init_workspace(workspace_id.as_str())?; + store + .workspace_store(workspace_id.as_str())? + .save_profile(&token)?; + tracing::debug!( + workspace = workspace_id.as_str(), + "token saved to workspace directory" + ); + + return Ok(token); + } + + // Read the body as text before parsing, and classify first. CTS + // reports a usage limit here as `access_denied` plus a `cs_code`, + // so matching on `error` alone would tell someone who is over + // their limit that they were denied access — and a bodyless 402 + // would surface as a JSON decode error rather than either. + let status = resp.status(); + let body = resp.text(); + if let Some(err) = crate::error::classify_issuance_failure(status, &body) { + return Err(err); + } + + let err: ErrorResponse = serde_json::from_str(&body).map_err(|e| { + AuthError::Server(crate::error::ServerError(format!( + "{status}: unparseable error body: {e}" + ))) + })?; + match err.error.as_str() { + "authorization_pending" => { + tracing::debug!("authorization pending, retrying"); + } + "slow_down" => { + interval += tokio::time::Duration::from_secs(5); + tracing::debug!(interval_secs = interval.as_secs(), "slowing down"); + } + "expired_token" => return Err(AuthError::TokenExpired(crate::error::TokenExpired)), + "access_denied" => return Err(AuthError::AccessDenied(crate::error::AccessDenied)), + "invalid_grant" => return Err(AuthError::InvalidGrant(crate::error::InvalidGrant)), + "invalid_client" => { + return Err(AuthError::InvalidClient(crate::error::InvalidClient)) + } + _ => { + return Err(AuthError::Server(crate::error::ServerError( + err.error_description, + ))) + } + } + + tokio::time::sleep(interval).await; + } + } +} diff --git a/packages/stack-auth/src/device_code/protocol.rs b/packages/stack-auth/src/device_code/protocol.rs new file mode 100644 index 000000000..dff033229 --- /dev/null +++ b/packages/stack-auth/src/device_code/protocol.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; +use vitaminc::protected::OpaqueDebug; +use zeroize::ZeroizeOnDrop; + +use crate::SecretToken; + +/// A device code issued by the auth server, exchanged for an access token +/// once the user authorizes. +#[derive(OpaqueDebug, ZeroizeOnDrop, Deserialize, Serialize)] +#[serde(transparent)] +pub(super) struct DeviceCode(String); + +#[derive(Deserialize)] +pub(super) struct DeviceCodeResponse { + pub device_code: DeviceCode, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + pub expires_in: u64, +} + +#[derive(Deserialize)] +pub(super) struct TokenResponse { + pub access_token: SecretToken, + pub token_type: String, + pub expires_in: u64, + #[serde(default)] + pub refresh_token: Option<SecretToken>, +} + +#[derive(Deserialize)] +pub(super) struct ErrorResponse { + pub error: String, + #[serde(default)] + pub error_description: String, +} + +#[derive(Serialize)] +pub(super) struct DeviceCodeRequest<'a> { + pub client_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub device_instance_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub device_name: Option<&'a str>, +} + +#[derive(Serialize)] +pub(super) struct TokenRequest<'a> { + pub client_id: &'a str, + pub device_code: &'a DeviceCode, + pub grant_type: &'a str, +} diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs new file mode 100644 index 000000000..c277b0bac --- /dev/null +++ b/packages/stack-auth/src/device_code/tests.rs @@ -0,0 +1,571 @@ +use super::*; +use cts_common::Region; +use mocktail::prelude::*; +use tempfile::TempDir; + +fn device_code_json() -> serde_json::Value { + serde_json::json!({ + "device_code": "test_device_code", + "user_code": "ABCD-EFGH", + "verification_uri": "http://example.com/activate", + "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", + "expires_in": 900 + }) +} + +/// Build a valid JWT access token containing a workspace claim. +fn test_access_token() -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-user", + "aud": "test-audience", + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "org_id": "org_test_default", + "scope": "", + }); + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .unwrap() +} + +fn token_json() -> serde_json::Value { + serde_json::json!({ + "access_token": test_access_token(), + "token_type": "Bearer", + "expires_in": 3600 + }) +} + +fn error_json(error: &str) -> serde_json::Value { + serde_json::json!({ + "error": error, + "error_description": format!("{error} occurred") + }) +} + +fn mock_code_endpoint(mocks: &mut MockSet) { + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.json(device_code_json()); + }); +} + +async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("stack-auth-test").with_mocks(mocks); + server.start().await.unwrap(); + server +} + +fn strategy_for(server: &MockServer, dir: &TempDir) -> DeviceCodeStrategy { + DeviceCodeStrategy::builder(Region::aws("ap-southeast-2").unwrap(), "cli") + .base_url(server.url("")) + .profile_dir(dir.path()) + .build() + .unwrap() +} + +// ---- begin() tests ---- + +#[tokio::test] +async fn test_begin_returns_pending_device_code() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + let server = start_server(mocks).await; + + let pending = strategy_for(&server, &dir).begin().await.unwrap(); + + assert_eq!(pending.user_code(), "ABCD-EFGH"); + assert_eq!(pending.verification_uri(), "http://example.com/activate"); + assert_eq!( + pending.verification_uri_complete(), + "http://example.com/activate?user_code=ABCD-EFGH" + ); + assert_eq!(pending.expires_in(), 900); +} + +#[tokio::test] +async fn test_begin_invalid_client() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.bad_request().json(error_json("invalid_client")); + }); + let server = start_server(mocks).await; + + let err = strategy_for(&server, &dir).begin().await.unwrap_err(); + + assert!(matches!(err, AuthError::InvalidClient(_))); +} + +#[tokio::test] +async fn test_begin_server_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.bad_request().json(error_json("server_error")); + }); + let server = start_server(mocks).await; + + let err = strategy_for(&server, &dir).begin().await.unwrap_err(); + + assert!( + matches!(&err, AuthError::Server(crate::error::ServerError(desc)) if desc == "server_error occurred") + ); +} + +// ---- poll_for_token() tests ---- + +/// Helper: calls begin() against a server that already has the code mock, +/// then returns the PendingDeviceCode ready for polling. +async fn begin_pending(server: &MockServer, dir: &TempDir) -> PendingDeviceCode { + strategy_for(server, dir).begin().await.unwrap() +} + +/// Stand in for the OS launcher only; `open_in_browser` itself is unchanged +/// between test and production builds. Thread-local expectations keep tests +/// independent even when run concurrently in one test binary. +pub(super) mod browser { + use std::cell::RefCell; + + thread_local! { + pub(super) static EXPECTED: RefCell<Option<(String, bool)>> = const { RefCell::new(None) }; + } + + pub(in crate::device_code) fn launch(uri: &str) -> std::io::Result<()> { + let (expected, succeeds) = EXPECTED + .with_borrow_mut(Option::take) + .expect("unexpected browser launch"); + assert_eq!(uri, expected, "open the complete verification URI"); + if succeeds { + Ok(()) + } else { + Err(std::io::Error::other("browser launcher failed")) + } + } +} + +#[tokio::test] +async fn opening_the_browser_reports_the_launchers_result() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + let server = start_server(mocks).await; + let pending = begin_pending(&server, &dir).await; + + for succeeds in [true, false] { + browser::EXPECTED.with_borrow_mut(|expected| { + *expected = Some(( + "http://example.com/activate?user_code=ABCD-EFGH".to_string(), + succeeds, + )); + }); + assert_eq!( + pending.open_in_browser(), + succeeds, + "browser launch result should match launcher success={succeeds}" + ); + browser::EXPECTED.with_borrow(|expected| { + assert!(expected.is_none(), "the launcher must actually be called"); + }); + } +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_success() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let token = begin_pending(&server, &dir) + .await + .poll_for_token() + .await + .unwrap(); + + assert_eq!(token.token_type(), "Bearer"); + assert!(!token.is_expired()); + assert!((3598..=3600).contains(&token.expires_in())); + assert_eq!( + token.workspace_id().unwrap().as_str(), + "ZVATKW3VHMFG27DY", + "workspace ID should be extracted from the JWT" + ); + + // Verify the token was persisted to the workspace directory + let store = ProfileStore::new(dir.path()); + assert_eq!( + store.current_workspace().unwrap(), + "ZVATKW3VHMFG27DY", + "current workspace should be set after poll_for_token" + ); +} + +/// The device-code start and the token poll both go to CTS, whose edge +/// refuses a request without a `user-agent` it accepts: each names the crate. +/// The mocks answer only a request that carries it. +#[tokio::test(start_paused = true)] +async fn device_code_requests_identify_the_crate() { + let user_agent = format!( + "stack-auth/{} ({} {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + ); + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + let code_agent = user_agent.clone(); + mocks.mock(move |when, then| { + when.post() + .path("/oauth/device/code") + .header("user-agent", code_agent); + then.json(device_code_json()); + }); + mocks.mock(move |when, then| { + when.post() + .path("/oauth/device/token") + .header("user-agent", user_agent); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let token = begin_pending(&server, &dir) + .await + .poll_for_token() + .await + .unwrap(); + + assert_eq!(token.token_type(), "Bearer"); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_access_denied() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("access_denied")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server, &dir) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::AccessDenied(_))); +} + +/// CTS reports a usage limit on this endpoint as `access_denied` plus a +/// `cs_code`, because RFC 6749 §5.2 fixes the legal `error` values. Matching +/// on `error` alone told someone who was over their limit that they had been +/// denied access — the exact confusion the taxonomy exists to remove. +#[tokio::test(start_paused = true)] +async fn poll_reports_a_usage_limit_not_access_denied() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.status(reqwest::StatusCode::PAYMENT_REQUIRED) + .json(serde_json::json!({ + "error": "access_denied", + "cs_code": "USAGE_LIMIT_EXCEEDED", + "error_description": "Workspace has exceeded its usage limit", + })); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server, &dir) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!( + matches!(err, AuthError::UsageLimitExceeded(_)), + "expected a usage limit, got {err:?}", + ); +} + +/// A 402 with no body at all used to surface as a reqwest decode error, +/// because the response was parsed as JSON before anything else looked at it. +#[tokio::test(start_paused = true)] +async fn poll_classifies_a_bodyless_402() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.status(reqwest::StatusCode::PAYMENT_REQUIRED); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server, &dir) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!( + matches!(err, AuthError::UsageLimitExceeded(_)), + "expected a usage limit, got {err:?}", + ); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_expired_token() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("expired_token")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server, &dir) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::TokenExpired(_))); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_invalid_grant() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server, &dir) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::InvalidGrant(_))); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_invalid_client() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("invalid_client")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server, &dir) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::InvalidClient(_))); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_unknown_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("something_unexpected")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server, &dir) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!( + matches!(&err, AuthError::Server(crate::error::ServerError(desc)) if desc == "something_unexpected occurred") + ); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_authorization_pending_then_success() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("authorization_pending")); + }); + let server = start_server(mocks).await; + let pending = begin_pending(&server, &dir).await; + + // Use tokio::join! so the swap future can borrow server.mocks() directly + // (the shared RwLock) rather than cloning the MockSet. + // First poll at T=5s returns "authorization_pending". + // At T=6s the mock is swapped. Second poll at T=10s returns success. + let (result, _) = tokio::join!(pending.poll_for_token(), async { + tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + }); + + let token = result.unwrap(); + assert_eq!(token.token_type(), "Bearer"); + assert!( + token.workspace_id().is_ok(), + "token should contain a valid workspace claim" + ); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_slow_down_then_success() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("slow_down")); + }); + let server = start_server(mocks).await; + let pending = begin_pending(&server, &dir).await; + + // First poll returns "slow_down", interval increases to 10s. + // Swap the mock to return success before the second poll. + let (result, _) = tokio::join!(pending.poll_for_token(), async { + tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + }); + + let token = result.unwrap(); + assert_eq!(token.token_type(), "Bearer"); + assert!( + token.workspace_id().is_ok(), + "token should contain a valid workspace claim" + ); +} + +/// Proves that `slow_down` increases the poll interval: with a short +/// `expires_in`, the increased interval pushes the next poll past the +/// deadline, causing a `TokenExpired` error. +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_slow_down_increases_interval() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + // expires_in = 12: without slow_down, second poll at T=10 is within + // the deadline. With slow_down, interval becomes 10s, so second poll + // at T=15 exceeds the 12s deadline. + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.json(serde_json::json!({ + "device_code": "test_device_code", + "user_code": "ABCD-EFGH", + "verification_uri": "http://example.com/activate", + "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", + "expires_in": 12 + })); + }); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("slow_down")); + }); + let server = start_server(mocks).await; + let pending = begin_pending(&server, &dir).await; + + let err = pending.poll_for_token().await.unwrap_err(); + + assert!(matches!(err, AuthError::TokenExpired(_))); +} + +// ---- ensure_trailing_slash / URL join tests ---- + +#[test] +fn test_ensure_trailing_slash_adds_slash() { + let url = Url::parse("http://localhost:3001").unwrap(); + let result = ensure_trailing_slash(url); + assert_eq!(result.as_str(), "http://localhost:3001/"); +} + +#[test] +fn test_ensure_trailing_slash_preserves_existing() { + let url = Url::parse("http://localhost:3001/").unwrap(); + let result = ensure_trailing_slash(url); + assert_eq!(result.as_str(), "http://localhost:3001/"); +} + +#[test] +fn test_ensure_trailing_slash_with_path() { + let url = Url::parse("http://localhost:3001/api/v1").unwrap(); + let result = ensure_trailing_slash(url); + assert_eq!(result.as_str(), "http://localhost:3001/api/v1/"); +} + +#[test] +fn test_relative_join_preserves_base_path() { + let base = ensure_trailing_slash(Url::parse("http://localhost:3001/api/v1").unwrap()); + let joined = base.join("oauth/device/code").unwrap(); + assert_eq!( + joined.as_str(), + "http://localhost:3001/api/v1/oauth/device/code" + ); +} + +#[test] +fn test_relative_join_on_root_url() { + let base = ensure_trailing_slash(Url::parse("http://localhost:3001").unwrap()); + let joined = base.join("oauth/device/code").unwrap(); + assert_eq!(joined.as_str(), "http://localhost:3001/oauth/device/code"); +} + +#[tokio::test] +async fn test_pending_device_code_debug_does_not_leak() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + let server = start_server(mocks).await; + + let pending = begin_pending(&server, &dir).await; + let debug = format!("{:?}", pending); + + assert!( + !debug.contains("test_device_code"), + "PendingDeviceCode Debug should not contain the device code, got: {debug}" + ); +} diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs new file mode 100644 index 000000000..a5be86a2f --- /dev/null +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -0,0 +1,484 @@ +use url::Url; + +use stack_profile::ProfileStore; +#[cfg(not(target_arch = "wasm32"))] +use stack_profile::{FileLockGuard, ProfileData}; + +use crate::refresher::Refresher; +use crate::transport::SharedTransport; +use crate::{AuthError, SecretToken, Token}; + +/// Implements [`Refresher`] using OAuth refresh tokens. +/// +/// Optionally owns a [`ProfileStore`] for persisting refreshed tokens to disk. +/// When the store is `None`, tokens are cached in memory only. On wasm32 the +/// embedding host must hold the refresh lock across the whole refresh call. +pub(crate) struct DeviceSessionRefresher { + store: Option<ProfileStore>, + base_url: Url, + client_id: String, + region: String, + device_instance_id: Option<String>, + transport: SharedTransport, +} + +impl DeviceSessionRefresher { + pub(crate) fn new( + store: Option<ProfileStore>, + base_url: Url, + client_id: impl Into<String>, + region: impl Into<String>, + device_instance_id: Option<String>, + transport: SharedTransport, + ) -> Self { + Self { + store, + base_url, + client_id: client_id.into(), + region: region.into(), + device_instance_id, + transport, + } + } +} + +impl Refresher for DeviceSessionRefresher { + type Credential = SecretToken; + + fn save(&self, _token: &Token) { + // No-op: persistence happens inside `refresh` while the cross-process + // file lock is held, so a sibling process can't observe a stale + // refresh token after we've burned it. Saving again here would + // either be a redundant rewrite of the same content or — worse, if + // the in-memory and on-disk tokens have diverged — clobber a sibling + // process's rotation result. + } + + fn try_credential(&self, token: Option<&mut Token>) -> Option<Self::Credential> { + token.and_then(|t| t.take_refresh_token()) + } + + fn restore(&self, token: &mut Token, credential: Self::Credential) { + token.refresh_token = Some(credential); + } + + async fn refresh(&self, credential: &Self::Credential) -> Result<Token, AuthError> { + // Cross-process refresh lock: only one process should be exchanging + // a refresh token with the upstream IdP at a time. Without this, + // two CLI invocations sharing `~/.cipherstash` both load the same + // refresh token, both POST `/oauth/token`, and the second one trips + // Clerk's refresh-token-rotation replay detection — Clerk revokes + // the entire chain and every subsequent attempt fails with + // "invalid grant". + // + // The lock is held across the HTTP exchange and the on-disk save so + // a sibling process picks up the rotated token before attempting + // its own refresh. + #[cfg(not(target_arch = "wasm32"))] + let _lock = self.acquire_refresh_lock().await?; + // On wasm32 this is the host's responsibility: the Go credential + // binding holds the sibling auth.json lock across its refresh export. + // WASI preview 1 has no file-lock operation for this arm to call. + + // After acquiring the lock, the disk may already hold a fresher + // token that another process just rotated to. Burn our (now-stale) + // credential against Clerk and we'd get "already used"; return the + // disk copy directly instead. + if let Some(disk_token) = self.load_freshly_refreshed_token(credential) { + tracing::debug!( + "refresh skipped: another process rotated the token while we waited on the lock" + ); + return Ok(disk_token); + } + + let mut token = Token::refresh_with( + &self.transport, + credential, + &self.base_url, + &self.client_id, + self.device_instance_id.as_deref(), + ) + .await?; + token.set_region(&self.region); + token.set_client_id(&self.client_id); + if let Some(ref id) = self.device_instance_id { + token.set_device_instance_id(id); + } + + // Persist while holding the lock — any sibling process waiting on + // the lock will read the rotated token on their next attempt and + // skip burning their stale credential. + self.persist_refreshed(&token)?; + + Ok(token) + } +} + +impl DeviceSessionRefresher { + /// Acquire the cross-process refresh lock on `auth.json`, off the async + /// runtime thread so we don't block other tasks. Returns `None` when no + /// `ProfileStore` is configured (in-memory refreshers can't race against + /// other processes since there's no shared state). + #[cfg(not(target_arch = "wasm32"))] + async fn acquire_refresh_lock(&self) -> Result<Option<FileLockGuard>, AuthError> { + let Some(store) = self.store.clone() else { + return Ok(None); + }; + let lock = tokio::task::spawn_blocking(move || store.lock_exclusive(Token::FILENAME)) + .await + .map_err(|e| { + AuthError::Server(crate::error::ServerError(format!( + "refresh lock task join failed: {e}" + ))) + })? + .map_err(|e| { + AuthError::Server(crate::error::ServerError(format!( + "failed to acquire refresh lock: {e}" + ))) + })?; + Ok(Some(lock)) + } + + /// Returns a token from disk if it has a *different* refresh token than + /// the stale credential we were about to burn — that's the signature of + /// another process having already rotated while we waited for the lock. + /// Returns `None` if there's no on-disk token, it has no refresh token, + /// or its refresh token still matches our credential (i.e. nothing has + /// rotated and we genuinely do need to refresh). + fn load_freshly_refreshed_token(&self, credential: &SecretToken) -> Option<Token> { + let store = self.store.as_ref()?; + let disk_token: Token = store.load_profile().ok()?; + let disk_refresh = disk_token.refresh_token()?; + if disk_refresh.as_str() != credential.as_str() { + Some(disk_token) + } else { + None + } + } + + /// Persist the freshly refreshed token to disk while the lock is held. + /// A failure here must reach the caller: returning a token while disk + /// still holds its consumed refresh token would hide a broken rotation. + fn persist_refreshed(&self, token: &Token) -> Result<(), AuthError> { + let Some(store) = &self.store else { + return Ok(()); + }; + store.save_profile(token).map_err(|err| { + tracing::error!(%err, "failed to persist refreshed token to disk"); + AuthError::from(err) + })?; + tracing::debug!("refreshed token saved to disk"); + Ok(()) + } +} + +#[cfg(test)] +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +mod tests { + use super::*; + use crate::transport::default_transport; + use mocktail::prelude::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + const WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY"; + + fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + } + + fn token_on_disk(access: &str, refresh: &str) -> Token { + Token { + access_token: SecretToken::new(access), + refresh_token: Some(SecretToken::new(refresh)), + token_type: "Bearer".to_string(), + expires_at: now() + 3600, + region: Some("ap-southeast-2.aws".to_string()), + client_id: Some("cli".to_string()), + device_instance_id: None, + } + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("oauth-refresher-lock-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + fn refresher_with_disk_token( + dir: &tempfile::TempDir, + base_url: Url, + on_disk: Token, + ) -> DeviceSessionRefresher { + let store = ProfileStore::new(dir.path()); + store.init_workspace(WORKSPACE_ID).unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store.save_profile(&on_disk).unwrap(); + DeviceSessionRefresher::new( + Some(ws_store), + base_url, + "cli", + "ap-southeast-2.aws", + None, + default_transport(), + ) + } + + /// If disk holds a different refresh token than the credential we're + /// holding, another process has already rotated. We must return the + /// disk token rather than burning our stale credential against Clerk + /// (which would respond "already used" and revoke the chain). + #[tokio::test] + async fn refresh_returns_disk_token_when_sibling_already_rotated() { + // Mock server that errors if hit — proves the HTTP refresh is + // skipped entirely on the lock-and-reload fast path. + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(serde_json::json!({ + "error": "invalid_grant", + "error_description": "must not be called" + })); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + + // Disk has v2 (the rotated refresh token); in-memory credential is + // v1 (stale — the one we'd otherwise replay). + let disk = token_on_disk("rotated-access", "rotated-refresh"); + let refresher = refresher_with_disk_token(&dir, server.url(""), disk); + + let stale_credential = SecretToken::new("stale-refresh"); + let result = refresher.refresh(&stale_credential).await.unwrap(); + + assert_eq!( + result.access_token().as_str(), + "rotated-access", + "refresh should return the disk-cached rotated token" + ); + assert_eq!( + result.refresh_token().unwrap().as_str(), + "rotated-refresh", + "rotated refresh token from disk should flow through" + ); + } + + /// If disk holds the *same* refresh token as our credential, no sibling + /// has rotated and we genuinely need to call Clerk. The refreshed + /// token must be persisted to disk while the lock is held so a sibling + /// won't replay it. + #[tokio::test] + async fn refresh_calls_upstream_and_persists_when_disk_matches() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(serde_json::json!({ + "access_token": "new-access", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh" + })); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + + let disk = token_on_disk("old-access", "matching-refresh"); + let refresher = refresher_with_disk_token(&dir, server.url(""), disk); + + let credential = SecretToken::new("matching-refresh"); + let result = refresher.refresh(&credential).await.unwrap(); + + assert_eq!( + result.access_token().as_str(), + "new-access", + "refresh should return the new access token" + ); + assert_eq!( + result.refresh_token().unwrap().as_str(), + "new-refresh", + "refresh should return the rotated refresh token" + ); + // The `/oauth/token` response carries neither; the refresher stamps + // them, and a token without its region cannot derive its workspace + // CRN on the next load. + assert_eq!( + result.region(), + Some("ap-southeast-2.aws"), + "refresh should preserve the region" + ); + assert_eq!( + result.client_id(), + Some("cli"), + "refresh should preserve the client id" + ); + + // Persistence must have happened inside refresh() while the lock + // was held — so disk now reflects the rotated state. + let on_disk: Token = ProfileStore::new(dir.path()) + .workspace_store(WORKSPACE_ID) + .unwrap() + .load_profile() + .unwrap(); + assert_eq!( + on_disk.access_token().as_str(), + "new-access", + "the rotated access token should be persisted" + ); + assert_eq!( + on_disk.refresh_token().unwrap().as_str(), + "new-refresh", + "the rotated refresh token should be persisted" + ); + assert_eq!( + on_disk.region(), + Some("ap-southeast-2.aws"), + "the region should be persisted" + ); + assert_eq!( + on_disk.client_id(), + Some("cli"), + "the client id should be persisted" + ); + } + + /// The refresh response does not echo the device instance (CIP-2793), so + /// a device-bound refresher re-attaches it: the next refresh has to + /// present the same instance, and it reads it from this token. + #[tokio::test] + async fn refresh_carries_the_device_instance_through() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(serde_json::json!({ + "access_token": "new-access", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh" + })); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + + let store = ProfileStore::new(dir.path()); + store.init_workspace(WORKSPACE_ID).unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store + .save_profile(&token_on_disk("old-access", "matching-refresh")) + .unwrap(); + let refresher = DeviceSessionRefresher::new( + Some(ws_store), + server.url(""), + "cli", + "ap-southeast-2.aws", + Some("device-7".to_string()), + default_transport(), + ); + + let result = refresher + .refresh(&SecretToken::new("matching-refresh")) + .await + .unwrap(); + assert_eq!( + result.device_instance_id(), + Some("device-7"), + "refresh should preserve the device instance" + ); + + let on_disk: Token = ProfileStore::new(dir.path()) + .workspace_store(WORKSPACE_ID) + .unwrap() + .load_profile() + .unwrap(); + assert_eq!( + on_disk.device_instance_id(), + Some("device-7"), + "the rotated token on disk should keep the device instance" + ); + } + + /// Concurrent in-process calls to `refresh` must not produce a stale + /// replay. The first to acquire the lock rotates; the second sees the + /// disk has changed and returns the disk token without burning its + /// stale credential. Verified by upstream call counter. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_refreshes_only_call_upstream_once() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let counter = Arc::new(AtomicUsize::new(0)); + let counter_clone = Arc::clone(&counter); + let app = axum::Router::new().route( + "/oauth/token", + axum::routing::post(move || { + let counter = Arc::clone(&counter_clone); + async move { + counter.fetch_add(1, Ordering::SeqCst); + // Small delay so the second caller is reliably waiting + // on the lock while we serve this response. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + axum::Json(serde_json::json!({ + "access_token": "rotated-access", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "rotated-refresh" + })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let base_url = Url::parse(&format!("http://{addr}")).unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + store.init_workspace(WORKSPACE_ID).unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store + .save_profile(&token_on_disk("old-access", "shared-refresh")) + .unwrap(); + + // Two separate DeviceSessionRefresher instances sharing the same on-disk + // profile — same shape as two processes with the same ~/.cipherstash. + let r1 = Arc::new(DeviceSessionRefresher::new( + Some(ws_store.clone()), + base_url.clone(), + "cli", + "ap-southeast-2.aws", + None, + default_transport(), + )); + let r2 = Arc::new(DeviceSessionRefresher::new( + Some(ws_store), + base_url, + "cli", + "ap-southeast-2.aws", + None, + default_transport(), + )); + + let cred1 = SecretToken::new("shared-refresh"); + let cred2 = SecretToken::new("shared-refresh"); + + let r1c = Arc::clone(&r1); + let h1 = tokio::spawn(async move { r1c.refresh(&cred1).await }); + let r2c = Arc::clone(&r2); + let h2 = tokio::spawn(async move { r2c.refresh(&cred2).await }); + + let (a, b) = tokio::join!(h1, h2); + let a = a.unwrap().unwrap(); + let b = b.unwrap().unwrap(); + + assert_eq!(a.access_token().as_str(), "rotated-access"); + assert_eq!(b.access_token().as_str(), "rotated-access"); + assert_eq!( + counter.load(Ordering::SeqCst), + 1, + "exactly one upstream refresh — the second caller must take the lock-and-reload fast path" + ); + } +} diff --git a/packages/stack-auth/src/device_session_strategy.rs b/packages/stack-auth/src/device_session_strategy.rs new file mode 100644 index 000000000..8596da179 --- /dev/null +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -0,0 +1,327 @@ +use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery}; +use tracing::warn; + +use stack_profile::ProfileStore; + +use crate::auto_refresh::AutoRefresh; +use crate::device_session_refresher::DeviceSessionRefresher; +use crate::transport::{self, SharedTransport}; +use crate::{ensure_trailing_slash, AuthError, AuthStrategy, HttpTransport, ServiceToken, Token}; + +/// An [`AuthStrategy`] that renews a CTS session minted by an interactive +/// OAuth login (the device-code flow), using its OAuth refresh token. +/// +/// This *renews* an existing CTS session — it cannot federate a raw +/// third-party JWT. For that, see [`OidcFederationStrategy`](crate::OidcFederationStrategy). +/// +/// # Construction +/// +/// Use [`DeviceSessionStrategy::with_token`] with a token obtained from a device code flow +/// (or any other OAuth flow) for in-memory caching only. Use +/// [`DeviceSessionStrategy::with_profile`] to load a token from disk and persist +/// refreshed tokens back to the store. +/// +/// # Example +/// +/// ```no_run +/// use stack_auth::{DeviceSessionStrategy, Token}; +/// use cts_common::Region; +/// +/// # fn run(token: Token) -> Result<(), Box<dyn std::error::Error>> { +/// let region = Region::aws("ap-southeast-2")?; +/// let strategy = DeviceSessionStrategy::with_token(region, "my-client-id", token).build()?; +/// # Ok(()) +/// # } +/// ``` +pub struct DeviceSessionStrategy { + crn: Option<Crn>, + inner: AutoRefresh<DeviceSessionRefresher>, +} + +impl DeviceSessionStrategy { + /// Return a builder for configuring a `DeviceSessionStrategy` from a token. + /// + /// The token's `region` and `client_id` fields are set before caching. + /// No token store is used — tokens are not persisted to disk. + pub fn with_token( + region: Region, + client_id: impl Into<String>, + token: Token, + ) -> DeviceSessionStrategyBuilder { + DeviceSessionStrategyBuilder { + source: OAuthTokenSource::Token { + region, + client_id: client_id.into(), + token, + }, + base_url_override: None, + transport: None, + } + } + + /// Return a builder for configuring a `DeviceSessionStrategy` from a profile store. + /// + /// The token is loaded from the store when [`DeviceSessionStrategyBuilder::build`] is called. + /// The builder allows further configuration (e.g. overriding the base URL) before building. + /// + /// The token must have `region` and `client_id` set (as saved by + /// `DeviceCodeStrategy` (native, with the `http` feature) or a prior + /// `DeviceSessionStrategy`). The store is used for persisting refreshed tokens. + pub fn with_profile(store: ProfileStore) -> DeviceSessionStrategyBuilder { + DeviceSessionStrategyBuilder { + source: OAuthTokenSource::Store(store), + base_url_override: None, + transport: None, + } + } + + /// Build from a workspace-scoped profile store. WASI hosts use this + /// after taking the sibling auth.json lock; the store is read when + /// `build` runs, so a sibling process's completed rotation is observed. + /// On wasm32 the host must hold that lock through `get_token`, including + /// the save, because WASI preview 1 has no file locking. + pub fn with_workspace_store(store: ProfileStore) -> DeviceSessionStrategyBuilder { + DeviceSessionStrategyBuilder { + source: OAuthTokenSource::WorkspaceStore(store), + base_url_override: None, + transport: None, + } + } + + /// Return the workspace CRN, if one was extracted from the token at build time. + pub fn workspace_crn(&self) -> Option<&Crn> { + self.crn.as_ref() + } +} + +impl AuthStrategy for &DeviceSessionStrategy { + async fn get_token(self) -> Result<ServiceToken, AuthError> { + Ok(self.inner.get_token().await?) + } +} + +/// Where the initial OAuth token comes from. +enum OAuthTokenSource { + /// A token provided directly (in-memory only, no store). + Token { + region: Region, + client_id: String, + token: Token, + }, + /// A token loaded from a persistent store. + Store(ProfileStore), + WorkspaceStore(ProfileStore), +} + +/// Builder for [`DeviceSessionStrategy`]. +/// +/// Created via [`DeviceSessionStrategy::with_token`] or [`DeviceSessionStrategy::with_profile`]. +pub struct DeviceSessionStrategyBuilder { + source: OAuthTokenSource, + base_url_override: Option<url::Url>, + transport: Option<SharedTransport>, +} + +impl DeviceSessionStrategyBuilder { + /// Send this strategy's requests through `transport` instead of the + /// bundled `reqwest` client. + /// + /// Without the `http` feature there is no bundled client, so this is + /// required; with it, this is how a host with its own HTTP stack (or a + /// test with a stub) takes over the wire without changing anything else + /// about the strategy. + pub fn transport(mut self, transport: impl HttpTransport) -> Self { + self.transport = Some(transport::share(transport)); + self + } + + /// [`transport`](Self::transport), for a caller that may or may not + /// have one — the auto strategy hands its own through. Native-only, + /// because on wasm32 the auto strategy has no profile to detect from. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn maybe_transport(mut self, transport: Option<SharedTransport>) -> Self { + self.transport = transport; + self + } + /// Override the CTS base URL resolved for this strategy. + /// + /// Takes precedence over both the `CS_CTS_HOST` environment variable and + /// the token issuer / region-derived service discovery. Use it to point a + /// single strategy instance at a specific CTS host — e.g. a self-hosted + /// CTS, or a local mock auth server in development — without relying on the + /// process-wide `CS_CTS_HOST`, which would redirect every other CTS client + /// sharing the process. + pub fn base_url(mut self, url: url::Url) -> Self { + self.base_url_override = Some(url); + self + } + + /// Build the [`DeviceSessionStrategy`]. + /// + /// Resolves the base URL in priority order: an explicit [`base_url`] + /// override, then the `CS_CTS_HOST` environment variable, then the token + /// issuer. + /// + /// [`base_url`]: Self::base_url + pub fn build(self) -> Result<DeviceSessionStrategy, AuthError> { + let Self { + source, + base_url_override, + transport, + } = self; + let transport = transport::resolve(transport)?; + match source { + OAuthTokenSource::Token { + region, + client_id, + token, + } => Self::build_from_token(region, client_id, token, base_url_override, transport), + OAuthTokenSource::Store(store) => { + let ws_store = store.current_workspace_store()?; + Self::build_from_workspace_store(ws_store, base_url_override, transport) + } + OAuthTokenSource::WorkspaceStore(store) => { + Self::build_from_workspace_store(store, base_url_override, transport) + } + } + } + + /// Build from a token supplied directly (in-memory only, no store). + fn build_from_token( + region: Region, + client_id: String, + mut token: Token, + base_url_override: Option<url::Url>, + transport: SharedTransport, + ) -> Result<DeviceSessionStrategy, AuthError> { + let base_url = match base_url_override { + Some(url) => url, + None => { + crate::cts_base_url_from_env()?.unwrap_or(CtsServiceDiscovery::endpoint(region)?) + } + }; + // Derive CRN from the explicit region parameter and the token's + // workspace claim. We can't use token.workspace_crn() here + // because set_region() hasn't been called on the token yet. + let crn = token + .workspace_id() + .map(|ws| Crn::new(region, ws)) + .map_err(|e| { + warn!("Could not extract workspace CRN from token: {e}"); + e + }) + .ok(); + let region_id = region.identifier(); + let device_instance_id = token.device_instance_id().map(String::from); + token.set_region(&region_id); + token.set_client_id(&client_id); + let refresher = DeviceSessionRefresher::new( + None, + ensure_trailing_slash(base_url), + &client_id, + &region_id, + device_instance_id, + transport, + ); + Ok(DeviceSessionStrategy { + crn, + inner: AutoRefresh::with_token(refresher, token), + }) + } + + /// Build from a token persisted in a [`ProfileStore`]. + fn build_from_workspace_store( + ws_store: ProfileStore, + base_url_override: Option<url::Url>, + transport: SharedTransport, + ) -> Result<DeviceSessionStrategy, AuthError> { + let token: Token = ws_store.load_profile()?; + + let region_str = token + .region() + .ok_or(AuthError::NotAuthenticated(crate::error::NotAuthenticated))? + .to_string(); + let client_id = token + .client_id() + .ok_or(AuthError::NotAuthenticated(crate::error::NotAuthenticated))? + .to_string(); + let crn = token + .workspace_crn() + .map_err(|e| { + warn!("Could not extract workspace CRN from token: {e}"); + e + }) + .ok(); + let device_instance_id = token.device_instance_id().map(String::from); + + let base_url = match base_url_override { + Some(url) => url, + None => crate::cts_base_url_from_env()?.unwrap_or(token.issuer()?), + }; + + let refresher = DeviceSessionRefresher::new( + Some(ws_store), + ensure_trailing_slash(base_url), + &client_id, + &region_str, + device_instance_id, + transport, + ); + Ok(DeviceSessionStrategy { + crn, + inner: AutoRefresh::with_token(refresher, token), + }) + } +} + +// Every test here builds a strategy with no transport of its own, which +// needs the bundled one. +#[cfg(test)] +#[cfg(feature = "http")] +mod tests { + use super::*; + use crate::test_support::{claims_with_workspace, jwt_token, raw_token}; + + fn base_url() -> url::Url { + "https://cts.example.com".parse().expect("valid url") + } + + #[test] + fn build_from_token_derives_crn_from_the_jwt_workspace_claim() { + let region = Region::aws("ap-southeast-2").expect("valid region"); + let token = jwt_token(claims_with_workspace("7366ITCXSAPCH5TN")); + + let strategy = DeviceSessionStrategy::with_token(region, "my-client", token) + .base_url(base_url()) + .build() + .expect("build should succeed for a valid token"); + + let crn = strategy + .workspace_crn() + .expect("CRN should be derived from the workspace claim") + .to_string(); + assert!(crn.starts_with("crn:"), "unexpected CRN: {crn}"); + assert!( + crn.contains("7366ITCXSAPCH5TN"), + "CRN should carry the token's workspace, got: {crn}" + ); + } + + #[test] + fn build_from_token_succeeds_without_a_crn_when_the_workspace_claim_is_absent() { + let region = Region::aws("ap-southeast-2").expect("valid region"); + // A non-JWT access token: the workspace claim can't be decoded, so CRN + // derivation is skipped (it is best-effort) but the build still succeeds. + let token = raw_token("not-a-jwt"); + + let strategy = DeviceSessionStrategy::with_token(region, "my-client", token) + .base_url(base_url()) + .build() + .expect("build should succeed even when the CRN can't be derived"); + + assert!( + strategy.workspace_crn().is_none(), + "CRN should be None when the token has no decodable workspace claim" + ); + } +} diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs new file mode 100644 index 000000000..d6acaf824 --- /dev/null +++ b/packages/stack-auth/src/error.rs @@ -0,0 +1,1558 @@ +//! Authentication error types. +//! +//! [`AuthError`] is the single canonical error enum. Each variant wraps a +//! dedicated struct that owns its `Display` message, `miette` diagnostic +//! (`help`/`url`) and machine-readable code, plus any structured payload — so +//! per-error logic lives with the error rather than in one central function. +//! +//! The enum is a thin dispatcher: `Display`/`Diagnostic` delegate to the inner +//! struct via `transparent`, and [`AuthError::error_code`] / the `Serialize` +//! impl delegate through `AuthError::kind`. Ergonomic `From<Foreign>` impls +//! keep `?` working at call sites that lift a foreign error directly. + +use std::convert::Infallible; + +use cts_common::protocol::{CS_CODE_ORG_NOT_PROVISIONED, CS_CODE_USAGE_LIMIT_EXCEEDED}; + +use crate::access_key; + +/// Behaviour shared by every concrete error wrapped in an [`AuthError`] variant. +/// +/// Implemented by the per-error structs so each owns its FFI code and any +/// structured payload; [`AuthError`] dispatches to it via `AuthError::kind`. +pub trait AuthErrorKind: std::error::Error + miette::Diagnostic { + /// Stable machine-readable identifier surfaced across FFI boundaries + /// (e.g. JS `Error.code`). Named `error_code` to avoid colliding with + /// `miette::Diagnostic::code`, inherited via the `Diagnostic` supertrait. + fn error_code(&self) -> &'static str; + + /// Extra structured fields for the FFI/TS failure payload, beyond the + /// `type`/`message`/`help`/`url` the enum emits generically. None by default. + fn payload(&self) -> serde_json::Map<String, serde_json::Value> { + serde_json::Map::new() + } +} + +/// The stable machine-readable error codes surfaced across FFI (JS `Error.code`, +/// the `AuthFailure` TS unions). Defined once here so each +/// [`AuthErrorKind::error_code`] impl and the [`AuthError::ERROR_CODES`] list +/// reference the same constant rather than repeating a magic string; a code +/// only ever changes in one place. `auth_error_code_is_stable_for_every_variant` +/// pins that every variant maps to one of these and that the list is exhaustive. +pub(crate) mod codes { + pub(crate) const REQUEST_ERROR: &str = "REQUEST_ERROR"; + pub(crate) const ACCESS_DENIED: &str = "ACCESS_DENIED"; + pub(crate) const INVALID_GRANT: &str = "INVALID_GRANT"; + pub(crate) const INVALID_CLIENT: &str = "INVALID_CLIENT"; + pub(crate) const INVALID_URL: &str = "INVALID_URL"; + pub(crate) const INVALID_REGION: &str = "INVALID_REGION"; + pub(crate) const INVALID_CRN: &str = "INVALID_CRN"; + pub(crate) const WORKSPACE_MISMATCH: &str = "WORKSPACE_MISMATCH"; + pub(crate) const INVALID_WORKSPACE_ID: &str = "INVALID_WORKSPACE_ID"; + pub(crate) const MISSING_WORKSPACE_CRN: &str = "MISSING_WORKSPACE_CRN"; + pub(crate) const NOT_AUTHENTICATED: &str = "NOT_AUTHENTICATED"; + pub(crate) const EXPIRED_TOKEN: &str = "EXPIRED_TOKEN"; + pub(crate) const INVALID_ACCESS_KEY: &str = "INVALID_ACCESS_KEY"; + pub(crate) const INVALID_TOKEN: &str = "INVALID_TOKEN"; + // Aliased rather than re-declared: `CS_CODE_USAGE_LIMIT_EXCEEDED` / + // `CS_CODE_ORG_NOT_PROVISIONED` are the wire values CTS actually sends + // (`classify_issuance_failure` matches against them directly), and + // `StickyDenial` round-trips through these FFI codes via `error_code()` + // / `from_error_code`. A second, independent literal here would let the + // two drift — editing one without the other silently breaks either the + // wire classification or denial replay. + pub(crate) const USAGE_LIMIT_EXCEEDED: &str = super::CS_CODE_USAGE_LIMIT_EXCEEDED; + pub(crate) const ORG_NOT_PROVISIONED: &str = super::CS_CODE_ORG_NOT_PROVISIONED; + pub(crate) const SERVER_ERROR: &str = "SERVER_ERROR"; + pub(crate) const ALREADY_CONSUMED: &str = "ALREADY_CONSUMED"; + pub(crate) const INTERNAL_ERROR: &str = "INTERNAL_ERROR"; + pub(crate) const CUSTOM: &str = "CUSTOM"; + pub(crate) const STORE_ERROR: &str = "STORE_ERROR"; +} + +// --------------------------------------------------------------------------- +// Per-error structs +// --------------------------------------------------------------------------- + +/// The request to the auth server failed (network error, timeout, etc.). +/// +/// The payload is always boxed, never a concrete `reqwest::Error`: Cargo +/// features are additive, so a type whose shape changes with `http` breaks any +/// no-http consumer the moment something else in the graph turns the feature +/// on. The box holds whatever the [`HttpTransport`](crate::HttpTransport) in +/// use reported — the bundled one's `reqwest::Error`, or a host transport's +/// own — or the encoder's or decoder's error for a body that did not +/// serialize or parse. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Request to the auth server failed: {0}")] +pub struct RequestError(pub Box<dyn std::error::Error + Send + Sync + 'static>); +impl AuthErrorKind for RequestError { + fn error_code(&self) -> &'static str { + codes::REQUEST_ERROR + } +} + +/// The user denied the authorization request. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Authorization was denied")] +pub struct AccessDenied; +impl AuthErrorKind for AccessDenied { + fn error_code(&self) -> &'static str { + codes::ACCESS_DENIED + } +} + +/// The grant type was rejected by the server. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid grant")] +pub struct InvalidGrant; +impl AuthErrorKind for InvalidGrant { + fn error_code(&self) -> &'static str { + codes::INVALID_GRANT + } +} + +/// The client ID is not recognized. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid client")] +pub struct InvalidClient; +impl AuthErrorKind for InvalidClient { + fn error_code(&self) -> &'static str { + codes::INVALID_CLIENT + } +} + +/// A URL could not be parsed. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid URL: {0}")] +pub struct InvalidUrl(pub url::ParseError); +impl AuthErrorKind for InvalidUrl { + fn error_code(&self) -> &'static str { + codes::INVALID_URL + } +} + +/// The requested region is not supported. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Unsupported region: {0}")] +#[diagnostic(help("Use a supported region, e.g. `ap-southeast-2.aws`."))] +pub struct UnsupportedRegion(pub cts_common::RegionError); +impl AuthErrorKind for UnsupportedRegion { + fn error_code(&self) -> &'static str { + codes::INVALID_REGION + } +} + +/// The workspace CRN could not be parsed. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid workspace CRN: {0}")] +#[diagnostic(help( + "A workspace CRN looks like `crn:<region>:<workspace-id>`, e.g. `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY`." +))] +pub struct InvalidCrn(pub cts_common::InvalidCrn); +impl AuthErrorKind for InvalidCrn { + fn error_code(&self) -> &'static str { + codes::INVALID_CRN + } +} + +/// The token issued by the auth server is for a different workspace than the +/// one configured on the strategy. Surfaces when the access key was minted for +/// a different workspace, or when the wrong CRN was passed. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Workspace mismatch: token issued for {token_workspace}, but strategy is configured for {expected_workspace}")] +#[diagnostic(help( + "The access key or workspace CRN is scoped to a different workspace than the one requested — check which workspace the credential belongs to." +))] +pub struct WorkspaceMismatch { + /// The workspace the strategy was configured for (from the CRN). + pub expected_workspace: cts_common::WorkspaceId, + /// The workspace the auth server's token actually carries. + pub token_workspace: cts_common::WorkspaceId, +} +impl AuthErrorKind for WorkspaceMismatch { + fn error_code(&self) -> &'static str { + codes::WORKSPACE_MISMATCH + } + fn payload(&self) -> serde_json::Map<String, serde_json::Value> { + [ + ( + "expected".to_string(), + self.expected_workspace.to_string().into(), + ), + ( + "actual".to_string(), + self.token_workspace.to_string().into(), + ), + ] + .into_iter() + .collect() + } +} + +/// The workspace ID could not be parsed. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid workspace ID: {0}")] +pub struct InvalidWorkspaceId(pub cts_common::InvalidWorkspaceId); +impl AuthErrorKind for InvalidWorkspaceId { + fn error_code(&self) -> &'static str { + codes::INVALID_WORKSPACE_ID + } +} + +/// An access key was provided but the workspace CRN is missing. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error( + "Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn" +)] +#[diagnostic(help( + "Most strategies need a workspace CRN — set the `CS_WORKSPACE_CRN` environment variable, or pass it explicitly, e.g. `AutoStrategyBuilder::with_workspace_crn`." +))] +pub struct MissingWorkspaceCrn; +impl AuthErrorKind for MissingWorkspaceCrn { + fn error_code(&self) -> &'static str { + codes::MISSING_WORKSPACE_CRN + } +} + +/// No credentials are available (e.g. not logged in, no access key configured). +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Not authenticated")] +#[diagnostic(help( + "Log in with `stash login`, or set `CS_CLIENT_ACCESS_KEY` for service-to-service auth." +))] +pub struct NotAuthenticated; +impl AuthErrorKind for NotAuthenticated { + fn error_code(&self) -> &'static str { + codes::NOT_AUTHENTICATED + } +} + +/// A token (access token or device code) has expired. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Token expired")] +pub struct TokenExpired; +impl AuthErrorKind for TokenExpired { + fn error_code(&self) -> &'static str { + codes::EXPIRED_TOKEN + } +} + +/// The access key string is malformed (e.g. missing `CSAK` prefix or `.`). +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid access key: {0}")] +#[diagnostic(help("Access keys have the form `CSAK<key-id>.<secret>`."))] +pub struct InvalidAccessKeyError(pub access_key::InvalidAccessKey); +impl AuthErrorKind for InvalidAccessKeyError { + fn error_code(&self) -> &'static str { + codes::INVALID_ACCESS_KEY + } +} + +/// The JWT could not be decoded or its claims are malformed. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid token: {0}")] +pub struct InvalidToken(pub String); +impl AuthErrorKind for InvalidToken { + fn error_code(&self) -> &'static str { + codes::INVALID_TOKEN + } +} + +/// The organisation has exhausted its usage allowance, so CTS declined to +/// issue a credential. +/// +/// Distinct from [`AccessDenied`] and [`ServerError`] because it is neither a +/// permissions problem nor a transient one: retrying cannot succeed until the +/// plan changes. A client that backs off and retries on `SERVER_ERROR` — the +/// reasonable default — would otherwise spin indefinitely against a condition +/// only a human with a credit card can clear. +/// +/// Carries the server's `error_description` verbatim so the operator-facing +/// wording stays owned by CTS rather than duplicated here. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("{0}")] +#[diagnostic( + help( + "The organisation has used its allowance for the current billing period. Upgrade the plan from the CipherStash dashboard, then retry." + ), + url("https://dashboard.cipherstash.com/billing") +)] +pub struct UsageLimitExceeded(pub String); + +impl UsageLimitExceeded { + /// Fallback message for a 402 whose body carried no usable description. + pub const DEFAULT_MESSAGE: &'static str = + "Workspace has exceeded its usage limit and cannot issue an access token"; +} + +/// The organisation is not a known customer in the usage system. +/// +/// Distinct from [`UsageLimitExceeded`] because the remedy is different and +/// the two are not interchangeable: there is no plan to upgrade, so telling +/// the caller to upgrade one sends them somewhere that cannot help. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("{0}")] +#[diagnostic( + help( + "The organisation is not set up for usage tracking. Contact CipherStash support — retrying and upgrading the plan will both fail." + ), + url("https://cipherstash.com/support") +)] +pub struct OrgNotProvisioned(pub String); + +impl OrgNotProvisioned { + /// Fallback message for a 402 whose body carried no usable description. + pub const DEFAULT_MESSAGE: &'static str = + "Organisation is not provisioned in the usage system and cannot issue an access token"; +} + +impl AuthErrorKind for OrgNotProvisioned { + fn error_code(&self) -> &'static str { + codes::ORG_NOT_PROVISIONED + } +} + +impl AuthErrorKind for UsageLimitExceeded { + fn error_code(&self) -> &'static str { + codes::USAGE_LIMIT_EXCEEDED + } +} + +/// An unexpected error was returned by the auth server. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Server error: {0}")] +pub struct ServerError(pub String); +impl AuthErrorKind for ServerError { + fn error_code(&self) -> &'static str { + codes::SERVER_ERROR + } +} + +/// A consumable handle (e.g. a device-code poll) was used after it had already +/// been consumed. A caller bug rather than an auth outcome, but surfaced as an +/// `AuthError` so it flows through the `Result` contract rather than throwing +/// across the FFI boundary. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Handle already consumed")] +pub struct AlreadyConsumed; +impl AuthErrorKind for AlreadyConsumed { + fn error_code(&self) -> &'static str { + codes::ALREADY_CONSUMED + } +} + +/// An internal invariant was violated (e.g. a poisoned lock). Should not occur +/// in correct usage; surfaced rather than panicking so it crosses the FFI +/// boundary as a `Result` failure. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Internal error: {0}")] +pub struct InternalError(pub String); +impl AuthErrorKind for InternalError { + fn error_code(&self) -> &'static str { + codes::INTERNAL_ERROR + } +} + +/// An auth failure that doesn't correspond to a specific [`AuthError`] variant. +/// +/// The catch-all for an error outside the standard set — a custom +/// [`AuthStrategy`](crate::AuthStrategy) surfacing its own failure, or an FFI +/// adaptor reconstructing a failure whose `type` code it can't rebuild into a +/// typed variant (a variant that wraps a foreign error, or an unrecognised +/// code). Mirrors serde's `Error::custom`: it carries the already-rendered +/// message verbatim (its `Display` is that message, with no added prefix), so +/// a reconstructed error reads exactly as it did on the far side of the +/// boundary. It serializes as `{ type: "CUSTOM", ... }`, so consumers switching +/// on the failure code must handle it. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("{0}")] +pub struct CustomError(pub String); +impl AuthErrorKind for CustomError { + fn error_code(&self) -> &'static str { + codes::CUSTOM + } +} + +/// A token store operation failed. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Token store error: {0}")] +pub struct StoreError(pub stack_profile::ProfileError); +impl AuthErrorKind for StoreError { + fn error_code(&self) -> &'static str { + codes::STORE_ERROR + } +} + +// --------------------------------------------------------------------------- +// The canonical enum +// --------------------------------------------------------------------------- + +/// Errors that can occur during an authentication flow. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] +pub enum AuthError { + #[error(transparent)] + #[diagnostic(transparent)] + Request(#[from] RequestError), + #[error(transparent)] + #[diagnostic(transparent)] + AccessDenied(#[from] AccessDenied), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidGrant(#[from] InvalidGrant), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidClient(#[from] InvalidClient), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidUrl(#[from] InvalidUrl), + #[error(transparent)] + #[diagnostic(transparent)] + Region(#[from] UnsupportedRegion), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidCrn(#[from] InvalidCrn), + #[error(transparent)] + #[diagnostic(transparent)] + WorkspaceMismatch(#[from] WorkspaceMismatch), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidWorkspaceId(#[from] InvalidWorkspaceId), + #[error(transparent)] + #[diagnostic(transparent)] + MissingWorkspaceCrn(#[from] MissingWorkspaceCrn), + #[error(transparent)] + #[diagnostic(transparent)] + NotAuthenticated(#[from] NotAuthenticated), + #[error(transparent)] + #[diagnostic(transparent)] + TokenExpired(#[from] TokenExpired), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidAccessKey(#[from] InvalidAccessKeyError), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidToken(#[from] InvalidToken), + #[error(transparent)] + #[diagnostic(transparent)] + UsageLimitExceeded(#[from] UsageLimitExceeded), + #[error(transparent)] + #[diagnostic(transparent)] + OrgNotProvisioned(#[from] OrgNotProvisioned), + #[error(transparent)] + #[diagnostic(transparent)] + Server(#[from] ServerError), + #[error(transparent)] + #[diagnostic(transparent)] + AlreadyConsumed(#[from] AlreadyConsumed), + #[error(transparent)] + #[diagnostic(transparent)] + Internal(#[from] InternalError), + #[error(transparent)] + #[diagnostic(transparent)] + Custom(#[from] CustomError), + #[error(transparent)] + #[diagnostic(transparent)] + Store(#[from] StoreError), +} + +impl AuthError { + /// True when the *credential itself* was refused — an expired, invalid or + /// consumed token, key or grant — so obtaining a fresh credential and + /// retrying is a sensible response. False for everything else: + /// authenticated-but-forbidden, server faults, and configuration or + /// transport problems that no amount of refreshing can fix. + /// + /// This classification lives here, next to the variants, because + /// `AuthError` is `#[non_exhaustive]`: a downstream `match` needs a `_` + /// arm, which silently mis-classifies every variant added later. Inside + /// this crate the match *is* exhaustive — adding a variant is a compile + /// error until it is classified. FFI front-ends (the wasm guest's status + /// mapping) key their "refresh the token and retry" signal off this. + pub fn is_credential_rejection(&self) -> bool { + match self { + AuthError::NotAuthenticated(_) + | AuthError::TokenExpired(_) + | AuthError::InvalidGrant(_) + | AuthError::InvalidClient(_) + | AuthError::InvalidAccessKey(_) + | AuthError::AlreadyConsumed(_) => true, + AuthError::Request(_) + | AuthError::AccessDenied(_) + | AuthError::InvalidUrl(_) + | AuthError::Region(_) + | AuthError::InvalidCrn(_) + | AuthError::WorkspaceMismatch(_) + | AuthError::InvalidWorkspaceId(_) + | AuthError::MissingWorkspaceCrn(_) + | AuthError::InvalidToken(_) + | AuthError::UsageLimitExceeded(_) + | AuthError::OrgNotProvisioned(_) + | AuthError::Server(_) + | AuthError::Internal(_) + | AuthError::Custom(_) => false, + AuthError::Store(_) => false, + } + } + + /// The complete set of codes [`AuthError::error_code`] can return — the + /// stable, machine-readable contract surfaced across FFI (JS `Error.code`, + /// Node-API codes, the `index.d.ts` / `wasm-inline.d.ts` `AuthFailure` + /// unions). The bindings derive their expected union from this constant + /// rather than re-scraping the per-error [`AuthErrorKind::error_code`] impls, + /// and `auth_error_code_is_stable_for_every_variant` pins that it stays in + /// lockstep with what `error_code` actually returns. + pub const ERROR_CODES: &'static [&'static str] = &[ + codes::REQUEST_ERROR, + codes::ACCESS_DENIED, + codes::INVALID_GRANT, + codes::INVALID_CLIENT, + codes::INVALID_URL, + codes::INVALID_REGION, + codes::INVALID_CRN, + codes::WORKSPACE_MISMATCH, + codes::INVALID_WORKSPACE_ID, + codes::MISSING_WORKSPACE_CRN, + codes::NOT_AUTHENTICATED, + codes::EXPIRED_TOKEN, + codes::INVALID_ACCESS_KEY, + codes::INVALID_TOKEN, + codes::USAGE_LIMIT_EXCEEDED, + codes::ORG_NOT_PROVISIONED, + codes::SERVER_ERROR, + codes::ALREADY_CONSUMED, + codes::INTERNAL_ERROR, + codes::CUSTOM, + codes::STORE_ERROR, + ]; + + /// Dispatch to the wrapped concrete error as a trait object. + fn kind(&self) -> &dyn AuthErrorKind { + match self { + Self::Request(e) => e, + Self::AccessDenied(e) => e, + Self::InvalidGrant(e) => e, + Self::InvalidClient(e) => e, + Self::InvalidUrl(e) => e, + Self::Region(e) => e, + Self::InvalidCrn(e) => e, + Self::WorkspaceMismatch(e) => e, + Self::InvalidWorkspaceId(e) => e, + Self::MissingWorkspaceCrn(e) => e, + Self::NotAuthenticated(e) => e, + Self::TokenExpired(e) => e, + Self::InvalidAccessKey(e) => e, + Self::InvalidToken(e) => e, + Self::UsageLimitExceeded(e) => e, + Self::OrgNotProvisioned(e) => e, + Self::Server(e) => e, + Self::AlreadyConsumed(e) => e, + Self::Internal(e) => e, + Self::Custom(e) => e, + Self::Store(e) => e, + } + } + + /// Stable machine-readable identifier for surfacing across FFI boundaries + /// (e.g. JS `Error.code`, Node-API error codes). Delegates to the wrapped + /// error's [`AuthErrorKind::error_code`]; every value it can return is + /// listed in [`AuthError::ERROR_CODES`]. + pub fn error_code(&self) -> &'static str { + self.kind().error_code() + } + + /// Whether re-issuing the same request could plausibly succeed. + /// + /// Transport failures and server faults are worth retrying. Everything + /// else returns the same answer until something outside this client + /// changes — a plan upgrade, a corrected config, a fresh login — so a + /// caller that retries on them only multiplies load against a decision + /// that has already been made. + /// + /// Matched exhaustively so a new variant has to state which side it is on. + /// Where a code's nature is genuinely unclear the answer is `true`: + /// wrongly treating a transient failure as permanent locks a client out, + /// which is worse than a retry that fails again. + pub fn is_retryable(&self) -> bool { + match self { + // Transient by nature — the network or the far side may recover. + Self::Request(_) | Self::Server(_) => true, + // Ours to fix rather than the caller's to retry, but an internal + // fault may be non-deterministic. + Self::Internal(_) => true, + // Opaque by construction: a reconstructed or user-supplied error + // whose cause we cannot classify. + Self::Custom(_) => true, + // Local persistence (cookie, KV, keychain) can fail transiently. + Self::Store(_) => true, + + // Settled answers. Retrying re-asks a question already answered. + Self::AccessDenied(_) + | Self::InvalidGrant(_) + | Self::InvalidClient(_) + | Self::InvalidUrl(_) + | Self::Region(_) + | Self::InvalidCrn(_) + | Self::WorkspaceMismatch(_) + | Self::InvalidWorkspaceId(_) + | Self::MissingWorkspaceCrn(_) + | Self::NotAuthenticated(_) + | Self::TokenExpired(_) + | Self::InvalidAccessKey(_) + | Self::InvalidToken(_) + | Self::UsageLimitExceeded(_) + | Self::OrgNotProvisioned(_) + | Self::AlreadyConsumed(_) => false, + } + } + + /// Whether this failure refuses the *account* — as opposed to the + /// credential presented — and is therefore safe to negatively-cache + /// across separate `get_token` calls until something outside the client + /// changes (a plan upgrade, provisioning). + /// + /// Deliberately narrower than [`is_retryable`](Self::is_retryable): most + /// non-retryable failures (`invalid_grant`, `invalid_client`, ...) are + /// verdicts on the *credential*, and a refresher restores that credential + /// precisely so a later attempt can succeed once the caller supplies a + /// good one — caching those would defeat the restore path. Only a + /// verdict on the account itself is safe to replay without re-asking. + /// + /// Matched exhaustively, like `is_retryable`, so a new variant has to + /// declare which side of this boundary it's on rather than silently not + /// being cached. + pub(crate) fn is_account_refusal(&self) -> bool { + match self { + Self::UsageLimitExceeded(_) | Self::OrgNotProvisioned(_) => true, + + Self::Request(_) + | Self::AccessDenied(_) + | Self::InvalidGrant(_) + | Self::InvalidClient(_) + | Self::InvalidUrl(_) + | Self::Region(_) + | Self::InvalidCrn(_) + | Self::WorkspaceMismatch(_) + | Self::InvalidWorkspaceId(_) + | Self::MissingWorkspaceCrn(_) + | Self::NotAuthenticated(_) + | Self::TokenExpired(_) + | Self::InvalidAccessKey(_) + | Self::InvalidToken(_) + | Self::Server(_) + | Self::AlreadyConsumed(_) + | Self::Internal(_) + | Self::Custom(_) => false, + Self::Store(_) => false, + } + } + + /// Reconstruct an `AuthError` from its stable FFI wire form — the `type` + /// code, rendered `message`, and structured `payload` a serialized + /// [`AuthError`] carries across the boundary (e.g. the `{ failure }` a + /// JS-supplied auth strategy returns; `payload` is the extra fields + /// [`AuthErrorKind::payload`] emits alongside `type`/`message`). + /// + /// This is the inverse an adaptor needs so that failures cross back into + /// Rust as real `AuthError`s rather than being flattened to a single opaque + /// variant: + /// + /// - the fixed-message unit codes map straight back to their variant; + /// - `WORKSPACE_MISMATCH` rebuilds from its `expected`/`actual` payload; + /// - every other code maps to [`AuthError::Custom`] (mirrors serde's + /// `Error::custom`), because the variants that wrap a foreign error + /// (`RequestError`, `InvalidUrl`, `UnsupportedRegion`, …) have no + /// constructor from a string, and `message` is the rendered `Display` + /// (e.g. `"Server error: …"`) — re-wrapping it in a prefixing variant + /// would double the prefix. + /// + /// `Custom` stores the message verbatim, so a reconstructed error still + /// reads exactly as it did on the far side. `error_code()` round-trips + /// exactly for the mapped codes and is `CUSTOM` otherwise. Pass an empty map + /// for `payload` when there are no structured fields. + pub fn from_error_code( + code: &str, + message: impl Into<String>, + payload: &serde_json::Map<String, serde_json::Value>, + ) -> Self { + match code { + codes::NOT_AUTHENTICATED => NotAuthenticated.into(), + codes::EXPIRED_TOKEN => TokenExpired.into(), + codes::ACCESS_DENIED => AccessDenied.into(), + codes::INVALID_GRANT => InvalidGrant.into(), + codes::INVALID_CLIENT => InvalidClient.into(), + codes::MISSING_WORKSPACE_CRN => MissingWorkspaceCrn.into(), + codes::ALREADY_CONSUMED => AlreadyConsumed.into(), + // Round-trips with its message, unlike the fixed-message unit + // codes above: the description is CTS's wording, not ours. Falls + // back to the same default the classifier uses, so an empty + // message never produces a blank `Display`. + codes::USAGE_LIMIT_EXCEEDED => UsageLimitExceeded(default_if_blank( + message, + UsageLimitExceeded::DEFAULT_MESSAGE, + )) + .into(), + codes::ORG_NOT_PROVISIONED => OrgNotProvisioned(default_if_blank( + message, + OrgNotProvisioned::DEFAULT_MESSAGE, + )) + .into(), + codes::WORKSPACE_MISMATCH => workspace_mismatch_from_payload(payload) + .unwrap_or_else(|| CustomError(message.into()).into()), + _ => CustomError(message.into()).into(), + } + } +} + +/// `message.trim()`, or `default` if that's blank — the shared fallback for +/// the account-refusal codes in [`AuthError::from_error_code`], so an empty +/// message never produces a blank `Display` and the two codes can't drift +/// apart in how they apply that fallback. +fn default_if_blank(message: impl Into<String>, default: &str) -> String { + let message = message.into(); + match message.trim() { + "" => default.to_string(), + _ => message, + } +} + +/// Rebuild a [`WorkspaceMismatch`] from the `expected`/`actual` fields +/// [`WorkspaceMismatch::payload`] emits. Returns `None` if either field is +/// absent or not a parseable workspace ID, so the caller can fall back to +/// [`AuthError::Custom`]. +fn workspace_mismatch_from_payload( + payload: &serde_json::Map<String, serde_json::Value>, +) -> Option<AuthError> { + let parse = + |key: &str| -> Option<cts_common::WorkspaceId> { payload.get(key)?.as_str()?.parse().ok() }; + Some( + WorkspaceMismatch { + expected_workspace: parse("expected")?, + token_workspace: parse("actual")?, + } + .into(), + ) +} + +/// Classify a failed CTS credential-issuance response. +/// +/// Returns `Some` only for conditions with a typed variant; `None` means the +/// caller should fall back to its own generic handling. Every issuance path +/// (`/api/authorize`, OIDC federation, `/oauth/token`) routes through here so +/// they cannot drift apart in how they classify the same server response. +/// +/// `402` is the discriminator. The OAuth paths must send +/// `error: "access_denied"` to stay RFC 6749-compliant, which is +/// indistinguishable from a genuine authorization refusal — so the status, not +/// the body, decides. `cs_code` is checked when present so that a future 402 +/// with a different meaning does not silently inherit this classification. +pub(crate) fn classify_issuance_failure(status: u16, body: &str) -> Option<AuthError> { + if status != 402 { + return None; + } + + // An empty body is the one shape a 402 from CTS itself can take without + // being JSON: pre-`cs_code` deployments sent no body at all for a usage + // limit, so that remains the reading for a bare 402. + if body.trim().is_empty() { + return Some(UsageLimitExceeded(UsageLimitExceeded::DEFAULT_MESSAGE.to_string()).into()); + } + + // Anything else has to actually parse as a JSON object to be CTS-shaped — + // a non-empty body that isn't valid JSON, or that parses to a JSON value + // that isn't an object (an array, a bare string, `null`, ...), is not a + // response CTS ever sends. That's a 402 from something else entirely — + // a proxy, a WAF, a gateway in front of CTS — and reporting it as a usage + // limit would sticky-cache a permanent, non-retryable refusal for a + // condition that may well be transient. Declining sends the caller down + // its own generic, retryable handling instead. + let parsed = serde_json::from_str::<serde_json::Value>(body) + .ok() + .filter(serde_json::Value::is_object)?; + + let field = |name: &str| -> Option<String> { + parsed + .get(name)? + .as_str() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }; + + // Presence is decided on the raw value, not on a string projection of it. + // Reading `cs_code` through `as_str()` would make a non-string value + // indistinguishable from an absent one, so `{"cs_code": 42}` would skip + // this guard entirely and be classified — the exact inversion of what the + // guard is for. Anything present but not recognised declines, which sends + // the caller down the generic path rather than asserting a remedy on the + // strength of a body we could not read. + // + // Compared explicitly with `==` rather than matched as patterns. Neither + // footgun that phrasing might suggest actually applies here: these names + // are brought into scope by `use`, so as bare-identifier patterns they'd + // correctly resolve to the consts and compare (not bind) — an unqualified + // identifier only binds when it fails to resolve to a const/unit-variant + // at all — and a *qualified* path that fails to resolve is a compile + // error (E0531), never a silent binding. `==` is simply the more obviously + // correct form, not a workaround for either. + let recognised = |code: &str| { + if code == CS_CODE_USAGE_LIMIT_EXCEEDED { + Some(Refusal::UsageLimit) + } else if code == CS_CODE_ORG_NOT_PROVISIONED { + Some(Refusal::NotProvisioned) + } else { + None + } + }; + + let refusal = match parsed.get("cs_code") { + Some(value) => recognised(value.as_str().map(str::trim).unwrap_or_default())?, + // Absent: pre-`cs_code` deployments only ever sent 402 for a usage + // limit, so that remains the reading for a bare 402. + None => Refusal::UsageLimit, + }; + + let description = field("error_description"); + + Some(match refusal { + Refusal::UsageLimit => UsageLimitExceeded( + description.unwrap_or_else(|| UsageLimitExceeded::DEFAULT_MESSAGE.to_string()), + ) + .into(), + Refusal::NotProvisioned => OrgNotProvisioned( + description.unwrap_or_else(|| OrgNotProvisioned::DEFAULT_MESSAGE.to_string()), + ) + .into(), + }) +} + +/// Which account-level refusal a 402 body describes. +enum Refusal { + UsageLimit, + NotProvisioned, +} + +/// Serialize an `AuthError` into the flat, FFI-facing shape consumed by the +/// Node and Wasm bindings: `{ type, message, help?, url?, ...payload }`. +/// +/// `type`/`message` come from the canonical code and `Display`; `help`/`url` +/// are captured generically from the `miette::Diagnostic` surface (so +/// per-variant help stays colocated on the struct); extra structured fields +/// come from [`AuthErrorKind::payload`]. +/// +/// `serialize_map` lets the wasm binding render this as a plain JS object via +/// `Serializer::serialize_maps_as_objects(true)`; serde_json renders a JSON +/// object directly. +impl serde::Serialize for AuthError { + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + use miette::Diagnostic; + use serde::ser::SerializeMap; + + let kind = self.kind(); + let mut map = serializer.serialize_map(None)?; + // Emit the per-variant payload first, then the fixed diagnostic fields — + // so if a future `payload()` key ever collided with `type`/`message`/ + // `help`/`url`, the diagnostic field (written last) wins rather than being + // clobbered. Mirrors the JS side's `{ ...payload, type, error }`. + for (key, value) in kind.payload() { + map.serialize_entry(&key, &value)?; + } + map.serialize_entry("type", kind.error_code())?; + map.serialize_entry("message", &self.to_string())?; + if let Some(help) = self.help() { + map.serialize_entry("help", &help.to_string())?; + } + if let Some(url) = self.url() { + map.serialize_entry("url", &url.to_string())?; + } + map.end() + } +} + +// --------------------------------------------------------------------------- +// Ergonomic `From<Foreign>` impls — keep `?` working where call sites lift a +// foreign error straight into `AuthError` (the per-struct wrapping is internal). +// --------------------------------------------------------------------------- + +#[cfg(feature = "http")] +impl From<reqwest::Error> for RequestError { + fn from(e: reqwest::Error) -> Self { + Self(Box::new(e)) + } +} + +impl From<url::ParseError> for AuthError { + fn from(e: url::ParseError) -> Self { + Self::InvalidUrl(InvalidUrl(e)) + } +} + +impl From<cts_common::RegionError> for AuthError { + fn from(e: cts_common::RegionError) -> Self { + Self::Region(UnsupportedRegion(e)) + } +} + +impl From<cts_common::InvalidCrn> for AuthError { + fn from(e: cts_common::InvalidCrn) -> Self { + Self::InvalidCrn(InvalidCrn(e)) + } +} + +impl From<cts_common::InvalidWorkspaceId> for AuthError { + fn from(e: cts_common::InvalidWorkspaceId) -> Self { + Self::InvalidWorkspaceId(InvalidWorkspaceId(e)) + } +} + +impl From<access_key::InvalidAccessKey> for AuthError { + fn from(e: access_key::InvalidAccessKey) -> Self { + Self::InvalidAccessKey(InvalidAccessKeyError(e)) + } +} + +impl From<stack_profile::ProfileError> for AuthError { + fn from(e: stack_profile::ProfileError) -> Self { + Self::Store(StoreError(e)) + } +} + +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +impl From<crate::DeviceClientError> for AuthError { + fn from(e: crate::DeviceClientError) -> Self { + use crate::DeviceClientError as E; + match e { + // Every non-`Auth` variant has a canonical `AuthError` equivalent — + // route through it so `bind_client_device` failures carry the same + // code/help/payload as every other path. `Auth` already is one. + E::Profile(e) => e.into(), + E::Auth(e) => e, + E::Request(e) => e.into(), + E::InvalidUrl(e) => e.into(), + E::Server { status, body } => { + Self::Server(ServerError(format!("ZeroKMS returned {status}: {body}"))) + } + } + } +} + +impl From<Infallible> for AuthError { + fn from(never: Infallible) -> Self { + match never {} + } +} + +#[cfg(test)] +#[cfg(feature = "http")] +mod classify_issuance_failure_tests { + use super::*; + + const OAUTH_402: &str = r#"{ + "error": "access_denied", + "error_description": "Workspace has exceeded its usage limit and cannot issue an access token", + "cs_code": "USAGE_LIMIT_EXCEEDED" + }"#; + + const AUTHORIZE_402: &str = r#"{ + "error": "usage_limit_exceeded", + "error_description": "Workspace has exceeded its usage limit and cannot issue an access token" + }"#; + + fn code_of(err: Option<AuthError>) -> Option<&'static str> { + err.map(|e| e.error_code()) + } + + #[test] + fn oauth_body_is_usage_limit_despite_access_denied_code() { + let err = classify_issuance_failure(402, OAUTH_402).expect("402 must classify"); + assert_eq!(err.error_code(), codes::USAGE_LIMIT_EXCEEDED); + assert!(err.to_string().contains("exceeded its usage limit")); + } + + #[test] + fn authorize_body_is_usage_limit() { + assert_eq!( + code_of(classify_issuance_failure(402, AUTHORIZE_402)), + Some(codes::USAGE_LIMIT_EXCEEDED), + ); + } + + /// Older CTS deployments predate `cs_code`; the status still carries the + /// meaning, so classification must not depend on the body. + #[test] + fn bare_402_without_body_still_classifies() { + let err = classify_issuance_failure(402, "").expect("402 must classify"); + assert_eq!(err.error_code(), codes::USAGE_LIMIT_EXCEEDED); + assert_eq!(err.to_string(), UsageLimitExceeded::DEFAULT_MESSAGE); + } + + /// A future 402 meaning something else must not silently inherit the + /// usage-limit classification. + #[test] + fn unknown_cs_code_declines_to_classify() { + let body = r#"{"error": "access_denied", "cs_code": "SOMETHING_ELSE"}"#; + assert!(classify_issuance_failure(402, body).is_none()); + } + + #[test] + fn non_402_statuses_are_left_alone() { + for status in [400, 401, 403, 404, 500, 503] { + assert!( + classify_issuance_failure(status, OAUTH_402).is_none(), + "status {status} must not be classified as a usage limit", + ); + } + } + + /// Regression: a `cs_code` we cannot read is not a `cs_code` we recognise. + /// + /// The first implementation projected the field through `.as_str()`, so a + /// non-string value read as *absent* and fell through to classification — + /// inverting the guard's whole purpose. Misclassifying here is the + /// expensive direction: it tells a caller to go buy something on the + /// strength of a body we failed to parse. + #[test] + fn unreadable_cs_code_declines_to_classify() { + for body in [ + r#"{"cs_code": 42}"#, + r#"{"cs_code": null}"#, + r#"{"cs_code": {}}"#, + r#"{"cs_code": []}"#, + r#"{"cs_code": true}"#, + r#"{"cs_code": ""}"#, + r#"{"cs_code": " "}"#, + ] { + assert!( + classify_issuance_failure(402, body).is_none(), + "a 402 carrying an unreadable cs_code must fall back, not claim a usage limit: {body}", + ); + } + } + + /// A 402 from something that is not CTS — a proxy, WAF, or payment gateway + /// in front of it — must not panic or be reported as a usage limit if it + /// carries a `cs_code` we do not recognise. + #[test] + fn non_object_and_non_json_bodies_are_handled() { + for body in [ + "<html>502 Bad Gateway</html>", + "[]", + "null", + "7", + "\"a string\"", + "", + " ", + "{", + ] { + // No `cs_code` is discoverable in any of these, so the documented + // bare-402 fallback applies; the contract is that it does not panic + // and always yields a usable message. + if let Some(err) = classify_issuance_failure(402, body) { + assert!( + !err.to_string().trim().is_empty(), + "classified error must carry a usable message for body: {body:?}", + ); + } + } + } + + /// A non-empty body that isn't CTS-shaped JSON — an HTML error page, a + /// bare JSON array/string/number, or outright invalid JSON — must decline + /// to classify rather than falling back to a usage limit. CTS itself + /// either sends no body at all (the legacy bare-402 case, still handled) + /// or a JSON object; anything else is a 402 from something in front of + /// CTS (a proxy, a WAF, a gateway), and sticky-caching a permanent, + /// non-retryable refusal for it would misdiagnose what could be a + /// transient condition. + #[test] + fn non_cts_shaped_bodies_decline_to_classify() { + for body in [ + "<html>502 Bad Gateway</html>", + "[]", + "null", + "7", + "\"a string\"", + "{", + "not json at all", + ] { + assert!( + classify_issuance_failure(402, body).is_none(), + "a 402 whose body is not CTS-shaped JSON must not be classified \ + as a usage limit: {body:?}", + ); + } + } + + mod properties { + use super::*; + use proptest::prelude::*; + + /// Bodies with the structure the classifier actually inspects. + /// + /// A bare `".*"` strategy essentially never produces parseable JSON, + /// so it exercises only the unparseable-body path and leaves the two + /// branches that carry logic — the `cs_code` guard and the description + /// extraction — with no property coverage at all. + fn issuance_body() -> impl Strategy<Value = String> { + let cs_code = prop_oneof![ + Just(None), + Just(Some(serde_json::json!(CS_CODE_USAGE_LIMIT_EXCEEDED))), + "[A-Z_]{1,20}".prop_map(|s| Some(serde_json::json!(s))), + Just(Some(serde_json::json!(42))), + Just(Some(serde_json::json!(null))), + Just(Some(serde_json::json!({}))), + Just(Some(serde_json::json!(" "))), + ]; + let description = prop_oneof![ + Just(None), + Just(Some(" ".to_string())), + "\\PC{1,64}".prop_map(Some), + ]; + + let structured = (cs_code, description).prop_map(|(cs, desc)| { + let mut obj = serde_json::Map::new(); + obj.insert("error".into(), serde_json::json!("access_denied")); + if let Some(cs) = cs { + obj.insert("cs_code".into(), cs); + } + if let Some(desc) = desc { + obj.insert("error_description".into(), serde_json::json!(desc)); + } + serde_json::Value::Object(obj).to_string() + }); + + prop_oneof![ + Just(String::new()), + Just("{".to_string()), + "\\PC{0,64}", + structured, + ] + } + + /// Whether a body permits classification, derived independently of + /// the implementation: an empty body always does (the legacy bare-402 + /// reading); a non-empty body only does if it parses as a JSON object + /// whose `cs_code` (if present at all) matches the usage-limit code. + fn cs_code_permits(body: &str) -> bool { + if body.trim().is_empty() { + return true; + } + let Ok(serde_json::Value::Object(obj)) = + serde_json::from_str::<serde_json::Value>(body) + else { + return false; + }; + obj.get("cs_code") + .is_none_or(|v| v.as_str().map(str::trim) == Some(CS_CODE_USAGE_LIMIT_EXCEEDED)) + } + + /// Statuses to classify against. + /// + /// 402 is drawn explicitly rather than left to chance. Over + /// `100..600`, 256 uniform draws miss 402 entirely about 60% of the + /// time — which would leave the positive direction of the equality + /// below untested in most runs, the same vacuity this replaces. + fn issuance_status() -> impl Strategy<Value = u16> { + prop_oneof![Just(402u16), 100u16..600] + } + + proptest! { + // Keep the case count modest so this stays a fast unit test. + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Classification happens exactly on the 402-with-compatible-cs_code + /// branch — no more, and importantly no less. + /// + /// Stated as an equality rather than "non-402 never classifies", so + /// a `classify_issuance_failure` that simply returned `None` fails + /// here. Asserting only the negative direction passes vacuously. + #[test] + fn classification_is_exactly_the_402_branch( + status in issuance_status(), + body in issuance_body(), + ) { + prop_assert_eq!( + classify_issuance_failure(status, &body).is_some(), + status == 402 && cs_code_permits(&body), + ); + } + + /// A classified usage limit always carries a usable message. An + /// empty one would reach the user as a blank "upgrade your plan". + /// + /// Guarded by `cs_code_permits` and then `expect`ed, rather than + /// wrapped in `if let Some`: a conditional body would hold however + /// little the classifier actually classified. + #[test] + fn classified_errors_always_carry_a_message(body in issuance_body()) { + prop_assume!(cs_code_permits(&body)); + let err = classify_issuance_failure(402, &body) + .expect("a 402 with a compatible cs_code must classify"); + prop_assert!(!err.to_string().trim().is_empty()); + prop_assert_eq!(err.error_code(), codes::USAGE_LIMIT_EXCEEDED); + } + + /// The message is either the body's own description or the + /// documented fallback — never anything invented in between. + #[test] + fn the_message_comes_from_the_body_or_the_default(body in issuance_body()) { + prop_assume!(cs_code_permits(&body)); + let err = classify_issuance_failure(402, &body) + .expect("a 402 with a compatible cs_code must classify"); + + let described = serde_json::from_str::<serde_json::Value>(&body) + .ok() + .and_then(|v| { + v.get("error_description")?.as_str().map(str::trim).map(str::to_string) + }) + .filter(|s| !s.is_empty()); + let rendered = err.to_string(); + + prop_assert!( + rendered == UsageLimitExceeded::DEFAULT_MESSAGE + || Some(&rendered) == described.as_ref(), + "message {rendered:?} came from neither the body nor the default", + ); + } + + /// A classified usage limit is never retryable. This is the + /// property the whole taxonomy exists to deliver. + #[test] + fn a_classified_usage_limit_is_never_retryable(body in issuance_body()) { + prop_assume!(cs_code_permits(&body)); + let err = classify_issuance_failure(402, &body) + .expect("a 402 with a compatible cs_code must classify"); + prop_assert!(!err.is_retryable()); + } + } + } + + /// Every code states which side of the retry boundary it is on, so adding + /// one to `ERROR_CODES` without deciding fails here rather than silently + /// inheriting a default. + #[test] + fn retryability_is_pinned_for_every_error_code() { + const RETRYABLE: &[&str] = &[ + codes::REQUEST_ERROR, + codes::SERVER_ERROR, + codes::INTERNAL_ERROR, + codes::CUSTOM, + #[cfg(not(target_arch = "wasm32"))] + codes::STORE_ERROR, + ]; + + let payload = serde_json::Map::new(); + for code in AuthError::ERROR_CODES { + // `from_error_code` degrades unmapped codes to `Custom`, which is + // retryable — so drive the check from a real instance where the + // code round-trips, and skip where it cannot be reconstructed. + let err = AuthError::from_error_code(code, "message", &payload); + if err.error_code() != *code { + continue; + } + assert_eq!( + err.is_retryable(), + RETRYABLE.contains(code), + "{code} is on the wrong side of the retry boundary", + ); + } + } + + /// Same contract as `retryability_is_pinned_for_every_error_code`, for the + /// account-refusal axis: every code states whether it's safe to + /// negatively-cache across `get_token` calls, so a new variant can't + /// silently fall out of storm suppression (or, worse, silently start + /// caching a credential-scoped failure that should have gone through the + /// restore path instead). + #[test] + fn account_refusal_is_pinned_for_every_error_code() { + const ACCOUNT_REFUSAL: &[&str] = &[codes::USAGE_LIMIT_EXCEEDED, codes::ORG_NOT_PROVISIONED]; + + let payload = serde_json::Map::new(); + for code in AuthError::ERROR_CODES { + let err = AuthError::from_error_code(code, "message", &payload); + if err.error_code() != *code { + continue; + } + assert_eq!( + err.is_account_refusal(), + ACCOUNT_REFUSAL.contains(code), + "{code} is on the wrong side of the account-refusal boundary", + ); + } + } + + /// An org the usage system has never heard of must not be told to upgrade + /// a plan it does not have. Both refusals travel as `access_denied` on a + /// 402, so `cs_code` is the only thing separating them. + #[test] + fn not_provisioned_is_not_reported_as_a_usage_limit() { + let body = r#"{"error":"access_denied","cs_code":"ORG_NOT_PROVISIONED", + "error_description":"Organisation is not provisioned"}"#; + + let err = classify_issuance_failure(402, body).expect("402 must classify"); + + assert_eq!(err.error_code(), codes::ORG_NOT_PROVISIONED); + assert!( + !err.to_string().to_lowercase().contains("upgrade"), + "there is no plan to upgrade: {err}", + ); + } + + /// The remedies differ, so the help text has to differ too — that text is + /// the whole reason for keeping the two codes apart. + #[test] + fn the_two_account_refusals_advise_differently() { + use miette::Diagnostic; + + let limit: AuthError = UsageLimitExceeded("over".into()).into(); + let missing: AuthError = OrgNotProvisioned("absent".into()).into(); + + let help = |e: &AuthError| e.help().map(|h| h.to_string()).unwrap_or_default(); + + assert!(help(&limit).to_lowercase().contains("upgrade")); + assert!(help(&missing).to_lowercase().contains("support")); + assert_ne!(help(&limit), help(&missing)); + } + + /// `help()` says what to do; `url()` says where — a caller building a UI + /// around this should be able to render an actual link, not just prose. + #[test] + fn the_two_account_refusals_link_to_where_to_act() { + use miette::Diagnostic; + + let limit: AuthError = UsageLimitExceeded("over".into()).into(); + let missing: AuthError = OrgNotProvisioned("absent".into()).into(); + + let url = |e: &AuthError| e.url().map(|u| u.to_string()); + + assert_eq!( + url(&limit).as_deref(), + Some("https://dashboard.cipherstash.com/billing"), + ); + assert_eq!( + url(&missing).as_deref(), + Some("https://cipherstash.com/support"), + ); + } + + /// Neither clears by asking again. + #[test] + fn both_account_refusals_are_non_retryable() { + let limit: AuthError = UsageLimitExceeded("over".into()).into(); + let missing: AuthError = OrgNotProvisioned("absent".into()).into(); + + assert!(!limit.is_retryable()); + assert!(!missing.is_retryable()); + } + + #[test] + fn not_provisioned_round_trips_through_error_code() { + let err = + AuthError::from_error_code(codes::ORG_NOT_PROVISIONED, "msg", &serde_json::Map::new()); + assert_eq!(err.error_code(), codes::ORG_NOT_PROVISIONED); + assert_eq!(err.to_string(), "msg"); + } + + /// A blank description must fall back rather than surfacing an empty + /// message to the user. + #[test] + fn blank_description_falls_back_to_default() { + let body = r#"{"error": "access_denied", "error_description": " "}"#; + let err = classify_issuance_failure(402, body).expect("402 must classify"); + assert_eq!(err.to_string(), UsageLimitExceeded::DEFAULT_MESSAGE); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_error_retains_store_type() { + let err = AuthError::from(stack_profile::ProfileError::NotFound { + path: "auth.json".into(), + }); + assert!(matches!(&err, AuthError::Store(StoreError(_)))); + assert_eq!(err.error_code(), codes::STORE_ERROR); + } + + /// The typed variant must survive the FFI round-trip; degrading to `CUSTOM` + /// would put clients back to string-matching the message. + #[test] + fn usage_limit_round_trips_through_error_code() { + let original = AuthError::UsageLimitExceeded(UsageLimitExceeded("over limit".into())); + let json = serde_json::to_value(&original).unwrap(); + assert_eq!(json["type"], "USAGE_LIMIT_EXCEEDED"); + assert!( + json.get("help").is_some(), + "the remedy should cross the boundary with the error", + ); + + let rebuilt = AuthError::from_error_code( + json["type"].as_str().unwrap(), + json["message"].as_str().unwrap(), + &serde_json::Map::new(), + ); + assert_eq!(rebuilt.error_code(), codes::USAGE_LIMIT_EXCEEDED); + assert_eq!(rebuilt.to_string(), original.to_string()); + } + + /// Same contract as `retryability_is_pinned_for_every_error_code`, for + /// the "refresh the credential and retry" axis the FFI front-ends key + /// off: a credential verdict must say so, and an account, authorisation + /// or transport failure must not send the caller round a refresh loop + /// that cannot fix it. + #[test] + fn credential_rejection_is_pinned_for_every_error_code() { + const CREDENTIAL_REJECTION: &[&str] = &[ + codes::NOT_AUTHENTICATED, + codes::EXPIRED_TOKEN, + codes::INVALID_GRANT, + codes::INVALID_CLIENT, + codes::INVALID_ACCESS_KEY, + codes::ALREADY_CONSUMED, + ]; + + let payload = serde_json::Map::new(); + let mut rejected_credentials = 0; + let mut other_errors = 0; + for code in AuthError::ERROR_CODES { + let err = AuthError::from_error_code(code, "message", &payload); + if err.error_code() != *code { + continue; + } + let expected = CREDENTIAL_REJECTION.contains(code); + assert_eq!( + err.is_credential_rejection(), + expected, + "{code} is on the wrong side of the credential-rejection boundary", + ); + if expected { + rejected_credentials += 1; + } else { + other_errors += 1; + } + } + assert!( + rejected_credentials > 0 && other_errors > 0, + "both sides of the boundary must be exercised: {rejected_credentials} credential rejections, {other_errors} other errors", + ); + + // `INVALID_ACCESS_KEY` does not round-trip through `from_error_code`, + // so build it the way a malformed key does. + let malformed_key = + AuthError::from("".parse::<crate::access_key::AccessKey>().unwrap_err()); + assert_eq!( + malformed_key.error_code(), + codes::INVALID_ACCESS_KEY, + "malformed access key should retain its error code" + ); + assert!( + malformed_key.is_credential_rejection(), + "malformed access key should be a credential rejection: {malformed_key:?}" + ); + } + + /// The account-refusal codes carry CTS's wording across the boundary, + /// but a blank one falls back to the default rather than rendering an + /// empty `Display`; a real message is kept exactly as given. + #[test] + fn from_error_code_falls_back_on_a_blank_account_refusal_message() { + let payload = serde_json::Map::new(); + for (code, default) in [ + ( + codes::USAGE_LIMIT_EXCEEDED, + UsageLimitExceeded::DEFAULT_MESSAGE, + ), + ( + codes::ORG_NOT_PROVISIONED, + OrgNotProvisioned::DEFAULT_MESSAGE, + ), + ] { + for blank in ["", " \t"] { + let err = AuthError::from_error_code(code, blank, &payload); + assert_eq!(err.error_code(), code, "blank message should retain {code}"); + assert_eq!(err.to_string(), default, "{code} with {blank:?}"); + } + let err = AuthError::from_error_code(code, " as sent ", &payload); + assert_eq!(err.to_string(), " as sent ", "{code} keeps its message"); + } + } + + #[test] + fn serialize_emits_type_message_help_and_payload() { + let expected: cts_common::WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap(); + let actual: cts_common::WorkspaceId = "AAAAAAAAAAAAAAAA".parse().unwrap(); + let (expected_s, actual_s) = (expected.to_string(), actual.to_string()); + + let err = AuthError::WorkspaceMismatch(WorkspaceMismatch { + expected_workspace: expected, + token_workspace: actual, + }); + let json = serde_json::to_value(&err).unwrap(); + + // Generic fields the enum emits for every variant. + assert_eq!(json["type"], "WORKSPACE_MISMATCH"); + assert_eq!(json["message"], err.to_string()); + // `help` comes from the miette diagnostic (present on this variant). + assert!(json.get("help").is_some(), "help should be serialized"); + // Structured payload from `AuthErrorKind::payload`. + assert_eq!(json["expected"], expected_s); + assert_eq!(json["actual"], actual_s); + } + + #[test] + fn serialize_variant_without_payload_emits_only_generic_fields() { + let err = AuthError::MissingWorkspaceCrn(MissingWorkspaceCrn); + let json = serde_json::to_value(&err).unwrap(); + + assert_eq!(json["type"], "MISSING_WORKSPACE_CRN"); + assert_eq!(json["message"], err.to_string()); + // No per-variant payload keys — the payload loop contributes nothing. + assert!(json.get("expected").is_none()); + assert!(json.get("actual").is_none()); + } + + /// Every `DeviceClientError` variant maps to its canonical `AuthError` + /// code so `bind_client_device` failures share the one envelope path. + #[cfg(all(feature = "http", not(target_arch = "wasm32")))] + #[test] + fn device_client_error_maps_to_canonical_auth_error() { + use crate::DeviceClientError as E; + + // `Auth` unwraps to the inner error unchanged. + assert_eq!( + AuthError::from(E::Auth(AuthError::AccessDenied(AccessDenied))).error_code(), + codes::ACCESS_DENIED, + ); + // `Request` carries the transport's error through as `REQUEST_ERROR`. + let request = AuthError::from(E::Request(RequestError(Box::new(std::io::Error::other( + "connection refused", + ))))); + assert_eq!(request.error_code(), codes::REQUEST_ERROR); + assert!( + request.to_string().contains("connection refused"), + "{request}" + ); + // Non-`Auth` variants route to their canonical `AuthError` equivalent. + assert_eq!( + AuthError::from(E::Profile(stack_profile::ProfileError::HomeDirNotFound)).error_code(), + codes::STORE_ERROR, + ); + assert_eq!( + AuthError::from(E::InvalidUrl("not a url".parse::<url::Url>().unwrap_err())) + .error_code(), + codes::INVALID_URL, + ); + let server = AuthError::from(E::Server { + status: 500, + body: "boom".to_string(), + }); + assert_eq!(server.error_code(), codes::SERVER_ERROR); + assert!( + server.to_string().contains("ZeroKMS returned 500: boom"), + "server error should preserve the status/body detail: {server}" + ); + } +} diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs new file mode 100644 index 000000000..88b81bcb4 --- /dev/null +++ b/packages/stack-auth/src/lib.rs @@ -0,0 +1,703 @@ +#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +// The README is the crate's front page, but nearly all of it — the strategy +// table, the quick-start examples, the links — is about the bundled HTTP +// strategies, which only exist with `http`. Including it unconditionally would +// leave a no-http build documenting (and doctesting) an API it does not have. +#![cfg_attr(feature = "http", doc = include_str!("../README.md"))] +#![cfg_attr( + not(feature = "http"), + doc = "Authentication strategies for [CipherStash](https://cipherstash.com) services." +)] +#![cfg_attr( + not(feature = "http"), + doc = "\nWithout the `http` feature this crate has no HTTP client of its own: the\ + strategies (`AutoStrategy`, `AccessKeyStrategy`, `DeviceSessionStrategy`,\ + `OidcFederationStrategy`) and the refresh engine are all here, and each\ + builder must be given an [`HttpTransport`] — how a host with its own\ + transport (a wasm module, say) runs them. [`AuthStrategyFn`] and\ + [`TokenStoreFn`] remain the escape hatches for acquisition and persistence\ + done entirely on the host's side. Enable the `http` feature for the bundled\ + `ReqwestTransport`, the native device-code flow, and the crate's full\ + documentation." +)] +// Security lints +#![deny(unsafe_code)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +// Prevent mem::forget from bypassing ZeroizeOnDrop +#![warn(clippy::mem_forget)] +// Prevent accidental data leaks via output +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Without `http` the crate has no HTTP client, not no strategies: `http` is +// the bundled `ReqwestTransport` and the two native flows that use it +// unconditionally (device binding, device code). Everything that needs +// reqwest by name carries its own `#[cfg(feature = "http")]` gate rather +// than a crate-wide `allow(dead_code)`, so the compiler verifies the +// partition in both directions. +// Relax in tests +#![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))] + +use std::future::Future; +use vitaminc::protected::OpaqueDebug; +use zeroize::ZeroizeOnDrop; + +mod access_key; +mod auth_strategy_fn; +mod clock; +mod error; +mod service_token; +mod token; +mod token_store; +mod transport; + +// The strategies that acquire and refresh tokens over HTTP, and the refresh +// engine they share. In every build: they send through whatever +// `HttpTransport` their builder was given, and only the bundled +// `ReqwestTransport` (their default) is behind the `http` feature. +mod access_key_refresher; +mod access_key_strategy; +mod authorize_dto; +mod auto_refresh; +mod auto_strategy; +mod device_session_refresher; +mod device_session_strategy; +mod oidc_federation_strategy; +mod oidc_refresher; +mod refresher; + +pub use error::StoreError; +pub use error::{ + AccessDenied, AlreadyConsumed, AuthError, AuthErrorKind, CustomError, InternalError, + InvalidAccessKeyError, InvalidClient, InvalidCrn, InvalidGrant, InvalidToken, InvalidUrl, + InvalidWorkspaceId, MissingWorkspaceCrn, NotAuthenticated, OrgNotProvisioned, RequestError, + ServerError, TokenExpired, UnsupportedRegion, UsageLimitExceeded, WorkspaceMismatch, +}; + +// Filesystem-backed device identity and the interactive device-code flow are +// native-only — both pull `stack-profile` (which uses `dirs` + `gethostname`) +// and the device-code flow launches a browser via `open::that`. Wasm consumers +// use `DeviceSessionStrategy::with_token` or `AccessKeyStrategy`. +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +mod device_client; +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +mod device_code; + +#[cfg(any(test, feature = "test-utils"))] +mod static_token_strategy; + +#[cfg(test)] +mod test_support; + +pub use access_key::{AccessKey, InvalidAccessKey}; +pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; +pub use auth_strategy_fn::AuthStrategyFn; +pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder}; +pub use device_session_strategy::{DeviceSessionStrategy, DeviceSessionStrategyBuilder}; +pub use oidc_federation_strategy::{OidcFederationStrategy, OidcFederationStrategyBuilder}; +pub use oidc_refresher::{OidcProvider, OidcProviderFn}; +pub use service_token::ServiceToken; +#[cfg(any(test, feature = "test-utils"))] +pub use static_token_strategy::StaticTokenStrategy; +pub use token::Token; +pub use token_store::{InMemoryTokenStore, NoStore, TokenStore, TokenStoreFn}; +#[cfg(feature = "http")] +pub use transport::ReqwestTransport; +pub use transport::{HttpRequest, HttpResponse, HttpTransport}; + +/// This crate's version: the product token in the `user-agent` every +/// request it builds carries (`stack-auth/<version> (<os> <arch>)`). A host +/// transport that replaces that value with one naming itself (the Go +/// binding's guest sends `stack-auth/<version> (Go)`) reads the version +/// here, so the product token means the same thing from every host. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Deprecated alias for [`DeviceSessionStrategy`]. +/// +/// Renamed to make the *renewal* (existing CTS session) vs *federation* +/// ([`OidcFederationStrategy`]) distinction explicit. The old name still +/// resolves so existing code keeps compiling; it will be removed in a future +/// major release. +#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategy`")] +pub type OAuthStrategy = DeviceSessionStrategy; + +/// Deprecated alias for [`DeviceSessionStrategyBuilder`]. +#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategyBuilder`")] +pub type OAuthStrategyBuilder = DeviceSessionStrategyBuilder; + +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +pub use device_client::{bind_client_device, DeviceClientError}; +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode}; + +// Re-exports from stack-profile for backward compatibility. +#[cfg(not(target_arch = "wasm32"))] +pub use stack_profile::DeviceIdentity; + +/// The workspace CRN every strategy is bound to, re-exported from +/// `cts-common`. +/// +/// A strategy built by hand takes one — `AccessKeyStrategy::new(crn, key)`, +/// `OidcFederationStrategy::new(crn, provider)` — so a caller that names its +/// own strategy needs this type and nothing else from `cts-common`. Its +/// region drives service discovery and its workspace id verifies every +/// token issued. +pub use cts_common::Crn; + +/// Token *acquisition* — strategies that produce a [`ServiceToken`]. +/// +/// Use [`AuthStrategy`] as the consumer-facing trait (e.g. when wiring +/// strategies into `cipherstash-client`). [`AuthStrategyFn`] is the +/// closure-shaped impl for callers that source tokens externally +/// (FFI, custom IPC). +/// +/// For the *persistence layer* — pluggable storage that slots into an +/// existing strategy — see [`crate::store`]. +/// +/// All items in this module are also re-exported at the crate root. +pub mod auth { + pub use crate::{ + AccessKey, AuthError, AuthStrategy, AuthStrategyBounds, AuthStrategyFn, HttpRequest, + HttpResponse, HttpTransport, InvalidAccessKey, SecretToken, ServiceToken, + }; + + #[cfg(feature = "http")] + pub use crate::ReqwestTransport; + + pub use crate::{ + AccessKeyStrategy, AccessKeyStrategyBuilder, AutoStrategy, AutoStrategyBuilder, + DeviceSessionStrategy, DeviceSessionStrategyBuilder, OidcFederationStrategy, + OidcFederationStrategyBuilder, OidcProvider, OidcProviderFn, + }; + + #[cfg(not(target_arch = "wasm32"))] + pub use crate::DeviceIdentity; + + #[cfg(all(feature = "http", not(target_arch = "wasm32")))] + pub use crate::{ + bind_client_device, DeviceClientError, DeviceCodeStrategy, DeviceCodeStrategyBuilder, + PendingDeviceCode, + }; + + #[cfg(any(test, feature = "test-utils"))] + pub use crate::StaticTokenStrategy; + + // Deprecated aliases, re-exported here too so `stack_auth::auth::OAuthStrategy` + // consumers keep compiling alongside the crate-root aliases. See the + // `OAuthStrategy` / `OAuthStrategyBuilder` definitions at the crate root. + #[allow(deprecated)] + pub use crate::{OAuthStrategy, OAuthStrategyBuilder}; +} + +/// Token *persistence* — pluggable backends for the service-token cache. +/// +/// Use [`TokenStore`] as the trait, [`TokenStoreFn`] for closure-shaped +/// impls (cookies, KV blobs, Redis), and [`InMemoryTokenStore`] / [`NoStore`] +/// for ready-made implementations. +/// +/// A `TokenStore` plugs into a concrete strategy via that strategy's +/// builder — it does *not* replace the strategy. For full token acquisition +/// (custom fetcher, FFI-hosted strategy), see [`crate::auth`]. +/// +/// For example, [`AccessKeyStrategyBuilder::with_token_store`](crate::AccessKeyStrategyBuilder::with_token_store). +/// +/// All items in this module are also re-exported at the crate root. +pub mod store { + pub use crate::{InMemoryTokenStore, NoStore, Token, TokenStore, TokenStoreFn}; +} + +/// A strategy for obtaining access tokens. +/// +/// Implementations handle all details of authentication, token caching, and +/// refresh. Callers just call [`get_token`](AuthStrategy::get_token) whenever +/// they need a valid token. +/// +/// The trait is designed to be implemented for `&T`, so that callers can use +/// shared references (e.g. `&DeviceSessionStrategy`) without consuming the strategy. +/// +/// # Token refresh +/// +/// All strategies that cache tokens ([`AccessKeyStrategy`], [`DeviceSessionStrategy`], +/// [`AutoStrategy`]) share the same internal refresh engine. Understanding the +/// refresh model helps predict how [`get_token`](AuthStrategy::get_token) +/// behaves under concurrent access. +/// +/// ## Expiry vs usability +/// +/// A token has two time thresholds: +/// +/// - **Expired** — the token is within **90 seconds** of its `expires_at` +/// timestamp. This triggers a preemptive refresh attempt. +/// - **Usable** — the token has **not yet reached** its `expires_at` timestamp. +/// A token can be "expired" (in the preemptive sense) but still "usable" +/// (the server will still accept it). +/// +/// ## Concurrent refresh strategies +/// +/// The gap between "expired" and "unusable" enables two refresh modes: +/// +/// 1. **Expiring but still usable** — The first caller triggers a background +/// refresh. Concurrent callers receive the current (still-valid) token +/// immediately without blocking. +/// 2. **Fully expired** — The first caller blocks while refreshing. Concurrent +/// callers wait until the refresh completes, then all receive the new token. +/// +/// Only one refresh runs at a time, regardless of how many callers request a +/// token concurrently. +/// +/// ## Flow diagram +/// +/// ```mermaid +/// flowchart TD +/// Start["get_token()"] --> Lock["Acquire lock"] +/// Lock --> Cached{Token cached?} +/// Cached -- No --> InitAuth["Authenticate +/// (lock held)"] +/// InitAuth -- OK --> ReturnNew["Return new token"] +/// InitAuth -- NotFound --> ErrNotFound["NotAuthenticated"] +/// InitAuth -- Err --> ErrAuth["Return error"] +/// Cached -- Yes --> CheckRefresh{Expired?} +/// +/// CheckRefresh -- "No (fresh)" --> ReturnOk["Return cached token"] +/// +/// CheckRefresh -- "Yes (needs refresh)" --> InProgress{Refresh in progress?} +/// InProgress -- Yes --> WaitOrReturn["Return token if usable, +/// else wait for refresh"] +/// WaitOrReturn -- OK --> ReturnOk +/// WaitOrReturn -- "refresh failed" --> ErrExpired["TokenExpired"] +/// +/// InProgress -- No --> HasCred{Refresh credential?} +/// HasCred -- None --> CheckUsable["Return token if usable, +/// else TokenExpired"] +/// +/// HasCred -- Yes --> Usable{Still usable?} +/// +/// Usable -- "Yes (preemptive)" --> NonBlocking["Refresh in background +/// (lock released)"] +/// NonBlocking --> ReturnOld["Return current token"] +/// +/// Usable -- "No (fully expired)" --> Blocking["Refresh +/// (lock held)"] +/// Blocking -- OK --> ReturnNew2["Return new token"] +/// Blocking -- Err --> ErrExpired["TokenExpired"] +/// ``` +#[cfg_attr(doc, aquamarine::aquamarine)] +#[cfg(not(target_arch = "wasm32"))] +pub trait AuthStrategy: Send { + /// Retrieve a valid access token, refreshing or re-authenticating as needed. + fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> + Send; +} + +/// Wasm32 variant of [`AuthStrategy`] — drops the `Send` bounds because +/// reqwest's fetch-backed futures aren't `Send` and edge runtimes are +/// single-threaded. +#[cfg(target_arch = "wasm32")] +pub trait AuthStrategy { + /// Retrieve a valid access token, refreshing or re-authenticating as needed. + fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>>; +} + +/// Marker trait alias for the bounds an owned `AuthStrategy`-providing +/// credential type `C` must satisfy when held inside a long-lived client +/// (e.g. `cipherstash_client::ZeroKMS<C>` shared across requests). +/// +/// - On native targets `C` must be `Send + Sync + 'static` so the client +/// can be carried across tokio task / `reqwest` worker boundaries. +/// - On `wasm32` the runtime is single-threaded and the typical credential +/// backing (a JS callable held by a `JsValue`) cannot cross threads +/// even in principle, so the `Send + Sync` requirement is dropped and +/// only `'static` remains. +/// +/// Implemented via a blanket impl — any type satisfying the per-target +/// bounds automatically implements `AuthStrategyBounds`. Callers don't +/// implement it directly. +/// +/// Mirrors the `cfg`-split already in place on [`AuthStrategy`] itself, +/// one layer up. Wasm consumers (e.g. `@cipherstash/protect-ffi` on +/// `wasm32-unknown-unknown`) can hold a `!Send + !Sync` credential type +/// without declaring `unsafe impl Send` / `Sync`. +#[cfg(not(target_arch = "wasm32"))] +pub trait AuthStrategyBounds: Send + Sync + 'static {} +#[cfg(not(target_arch = "wasm32"))] +impl<T: Send + Sync + 'static> AuthStrategyBounds for T {} + +#[cfg(target_arch = "wasm32")] +pub trait AuthStrategyBounds: 'static {} +#[cfg(target_arch = "wasm32")] +impl<T: 'static> AuthStrategyBounds for T {} + +/// A sensitive token string that is zeroized on drop and hidden from debug output. +/// +/// `SecretToken` wraps a `String` and enforces two invariants: +/// +/// - **Zeroized on drop**: the backing memory is overwritten with zeros when +/// the token goes out of scope, preventing it from lingering in memory. +/// - **Opaque debug**: the [`Debug`] implementation prints `"***"` instead of +/// the actual value, so tokens won't leak into logs or error messages. +/// +/// Use [`SecretToken::new`] to wrap a string value (e.g. an access key +/// loaded from configuration or an environment variable). +#[derive(Clone, OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)] +#[serde(transparent)] +pub struct SecretToken(String); + +impl SecretToken { + /// Create a new `SecretToken` from a string value. + pub fn new(value: impl Into<String>) -> Self { + Self(value.into()) + } + + /// Expose the inner token string for FFI boundaries. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Read the `CS_CTS_HOST` environment variable and parse it as a URL. +/// +/// Returns `Ok(None)` if the variable is not set or empty. +/// Returns `Ok(Some(url))` if the variable is set and valid. +/// Returns `Err(_)` if the variable is set but not a valid URL. +pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> { + match std::env::var("CS_CTS_HOST") { + Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)), + _ => Ok(None), + } +} + +/// Ensure a URL has a trailing slash so that `Url::join` with relative paths +/// appends to the path rather than replacing the last segment. +pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url { + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + url +} + +/// Decode a JWT payload by splitting on `.`, base64-decoding the middle +/// segment, and deserializing the JSON. Signatures are **not** verified — we +/// only ever read claims from a token we already hold. +/// +/// This is the single decode path on every target. It deliberately avoids +/// `jsonwebtoken`: on wasm32 that crate pulls `ring` (which won't build), and on +/// native, `jsonwebtoken` 10 rejects any token whose header carries a non-string +/// field (e.g. Clerk's `srf: true`) before it even looks at the claims. +pub(crate) fn decode_jwt_payload<C>(token: &str) -> Result<C, AuthError> +where + C: serde::de::DeserializeOwned, +{ + use base64::Engine; + let segments: Vec<&str> = token.split('.').collect(); + if segments.len() != 3 { + return Err(AuthError::InvalidToken(error::InvalidToken( + "JWT must have three segments".to_string(), + ))); + } + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(segments[1]) + .map_err(|e| { + AuthError::InvalidToken(error::InvalidToken(format!("base64 decode failed: {e}"))) + })?; + serde_json::from_slice(&payload).map_err(|e| { + AuthError::InvalidToken(error::InvalidToken(format!( + "failed to decode JWT claims: {e}" + ))) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The `error_code` strings are a stable contract surfaced across FFI + /// (JS `Error.code`, Node-API codes), so pin every variant's code. If a + /// new variant is added without a code, `error_code`'s exhaustive `kind()` + /// dispatch fails to compile, so the contract can't silently drift. + /// + /// Also pins [`AuthError::ERROR_CODES`] against what `error_code` actually + /// returns: every constructed variant's code must be declared there, and + /// `ERROR_CODES` must hold exactly those codes. So the list can't grow + /// stale entries or omit a real one — which is what the binding crates' + /// union tests trust. + #[test] + #[allow(clippy::unwrap_used)] + fn auth_error_code_is_stable_for_every_variant() { + use std::collections::BTreeSet; + + let workspace = "ZVATKW3VHMFG27DY" + .parse::<cts_common::WorkspaceId>() + .unwrap(); + + let cases: Vec<(AuthError, &str)> = vec![ + ( + AuthError::AccessDenied(crate::error::AccessDenied), + "ACCESS_DENIED", + ), + ( + AuthError::TokenExpired(crate::error::TokenExpired), + "EXPIRED_TOKEN", + ), + ( + AuthError::InvalidGrant(crate::error::InvalidGrant), + "INVALID_GRANT", + ), + ( + AuthError::InvalidClient(crate::error::InvalidClient), + "INVALID_CLIENT", + ), + ( + AuthError::NotAuthenticated(crate::error::NotAuthenticated), + "NOT_AUTHENTICATED", + ), + ( + AuthError::MissingWorkspaceCrn(crate::error::MissingWorkspaceCrn), + "MISSING_WORKSPACE_CRN", + ), + ( + AuthError::AlreadyConsumed(crate::error::AlreadyConsumed), + "ALREADY_CONSUMED", + ), + ( + AuthError::Server(crate::error::ServerError("boom".into())), + "SERVER_ERROR", + ), + ( + AuthError::Internal(crate::error::InternalError("boom".into())), + "INTERNAL_ERROR", + ), + ( + AuthError::InvalidToken(crate::error::InvalidToken("malformed".into())), + "INVALID_TOKEN", + ), + ( + AuthError::OrgNotProvisioned(crate::error::OrgNotProvisioned( + "not provisioned".into(), + )), + "ORG_NOT_PROVISIONED", + ), + ( + AuthError::UsageLimitExceeded(crate::error::UsageLimitExceeded( + "over limit".into(), + )), + "USAGE_LIMIT_EXCEEDED", + ), + ( + AuthError::Custom(crate::error::CustomError("boom".into())), + "CUSTOM", + ), + ( + AuthError::Request(crate::error::RequestError(Box::new(std::io::Error::other( + "connection refused", + )))), + "REQUEST_ERROR", + ), + ( + AuthError::from("not a url".parse::<url::Url>().unwrap_err()), + "INVALID_URL", + ), + ( + AuthError::from("not-a-region".parse::<cts_common::Region>().unwrap_err()), + "INVALID_REGION", + ), + ( + AuthError::from("not-a-crn".parse::<cts_common::Crn>().unwrap_err()), + "INVALID_CRN", + ), + ( + AuthError::from("!".parse::<cts_common::WorkspaceId>().unwrap_err()), + "INVALID_WORKSPACE_ID", + ), + ( + AuthError::from("".parse::<crate::access_key::AccessKey>().unwrap_err()), + "INVALID_ACCESS_KEY", + ), + ( + AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch { + expected_workspace: workspace, + token_workspace: workspace, + }), + "WORKSPACE_MISMATCH", + ), + #[cfg(not(target_arch = "wasm32"))] + ( + AuthError::from(stack_profile::ProfileError::HomeDirNotFound), + "STORE_ERROR", + ), + ]; + + let declared: BTreeSet<&str> = AuthError::ERROR_CODES.iter().copied().collect(); + + let mut from_variants: BTreeSet<&str> = BTreeSet::new(); + for (err, expected) in cases { + assert_eq!(err.error_code(), expected, "error_code for {err:?}"); + assert!( + declared.contains(expected), + "{expected} is returned by error_code() but missing from AuthError::ERROR_CODES", + ); + from_variants.insert(expected); + } + + assert_eq!( + declared, from_variants, + "AuthError::ERROR_CODES drifted from the codes error_code() returns", + ); + } + + /// `from_error_code` reconstructs the fixed-message unit variants and + /// `WORKSPACE_MISMATCH` (from its payload) to their own code, and everything + /// else — message-carrying, foreign-wrapping, or unrecognised codes — to + /// `Custom`, preserving the message verbatim. + #[test] + fn from_error_code_maps_known_codes_and_falls_back_to_custom() { + use crate::AuthErrorKind; + + let empty = serde_json::Map::new(); + + for code in [ + "NOT_AUTHENTICATED", + "EXPIRED_TOKEN", + "ACCESS_DENIED", + "INVALID_GRANT", + "INVALID_CLIENT", + "MISSING_WORKSPACE_CRN", + "ALREADY_CONSUMED", + ] { + let err = AuthError::from_error_code(code, "unused for unit variants", &empty); + assert_eq!(err.error_code(), code, "unit code should round-trip"); + assert!( + !matches!(err, AuthError::Custom(_)), + "{code} should map to its typed variant, not Custom", + ); + } + + // WORKSPACE_MISMATCH rebuilds from the exact `payload()` it serialized + // with — round-tripping the code (message is re-derived from the fields). + let workspace = "ZVATKW3VHMFG27DY" + .parse::<cts_common::WorkspaceId>() + .unwrap(); + let payload = crate::error::WorkspaceMismatch { + expected_workspace: workspace, + token_workspace: workspace, + } + .payload(); + let err = AuthError::from_error_code("WORKSPACE_MISMATCH", "unused", &payload); + assert_eq!(err.error_code(), "WORKSPACE_MISMATCH"); + assert!(!matches!(err, AuthError::Custom(_))); + + // A message-carrying variant, a foreign-wrapping one, WORKSPACE_MISMATCH + // with no usable payload, and an unrecognised code all collapse to Custom + // with the message kept as-is (no double-applied `Display` prefix). + for code in [ + "SERVER_ERROR", + "REQUEST_ERROR", + "WORKSPACE_MISMATCH", + "SOME_UNRECOGNISED_CODE", + ] { + let err = AuthError::from_error_code(code, "Server error: boom", &empty); + assert_eq!(err.error_code(), "CUSTOM", "{code} should map to Custom"); + assert_eq!( + err.to_string(), + "Server error: boom", + "Custom preserves the wire message verbatim", + ); + } + } + + /// Every variant annotated with `#[diagnostic(help(..))]` must surface that + /// help through `miette::Diagnostic` — it's what the CLI renders below the + /// error message. Unlike `error_code`'s exhaustive match, `help` is optional + /// and silently compiles if dropped, so pin all six (and a couple of + /// un-annotated variants that must stay `None`) explicitly. + #[test] + fn annotated_variants_expose_diagnostic_help() { + use miette::Diagnostic; + + let workspace = "ZVATKW3VHMFG27DY" + .parse::<cts_common::WorkspaceId>() + .unwrap(); + + // (variant, substring its help must contain) — one row per annotation. + let with_help: Vec<(AuthError, &str)> = vec![ + ( + AuthError::from("not-a-region".parse::<cts_common::Region>().unwrap_err()), + "supported region", + ), + ( + AuthError::from("not-a-crn".parse::<cts_common::Crn>().unwrap_err()), + "crn:<region>:<workspace-id>", + ), + ( + AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch { + expected_workspace: workspace, + token_workspace: workspace, + }), + "different workspace", + ), + ( + AuthError::MissingWorkspaceCrn(crate::error::MissingWorkspaceCrn), + "CS_WORKSPACE_CRN", + ), + ( + AuthError::NotAuthenticated(crate::error::NotAuthenticated), + "stash login", + ), + ( + AuthError::from("".parse::<crate::access_key::AccessKey>().unwrap_err()), + "CSAK<key-id>.<secret>", + ), + ]; + + for (err, substring) in with_help { + let help = err.help().map(|h| h.to_string()); + assert!( + help.as_deref().is_some_and(|h| h.contains(substring)), + "{err:?} should carry help containing {substring:?}, got: {help:?}", + ); + } + + // Un-annotated variants must report no help — keeps the contract + // symmetric so a stray annotation doesn't slip in unnoticed. + for err in [ + AuthError::TokenExpired(crate::error::TokenExpired), + AuthError::InvalidToken(crate::error::InvalidToken("malformed".to_string())), + ] { + assert!( + err.help().is_none(), + "{err:?} has no #[diagnostic(help)] and should report None", + ); + } + } + + /// `CS_CTS_HOST` overrides CTS discovery only when it holds something: + /// set-but-empty reads as unset (a shell that exports the variable blank + /// must not become a URL parse error), and a value that is there is + /// parsed, not ignored. + #[test] + fn cts_base_url_from_env_treats_empty_as_unset() { + let read = + |value: Option<&str>| temp_env::with_var("CS_CTS_HOST", value, cts_base_url_from_env); + + assert!(matches!(read(None), Ok(None)), "unset → no override"); + assert!(matches!(read(Some("")), Ok(None)), "empty → no override"); + assert_eq!( + read(Some("https://cts.example.com/")) + .expect("a valid URL parses") + .map(String::from), + Some("https://cts.example.com/".to_string()), + ); + assert!( + matches!(read(Some("not a url")), Err(AuthError::InvalidUrl(_))), + "a malformed override is an error, not ignored", + ); + } +} diff --git a/packages/stack-auth/src/oidc_federation_strategy.rs b/packages/stack-auth/src/oidc_federation_strategy.rs new file mode 100644 index 000000000..435072f2a --- /dev/null +++ b/packages/stack-auth/src/oidc_federation_strategy.rs @@ -0,0 +1,512 @@ +use cts_common::{Crn, CtsServiceDiscovery, ServiceDiscovery, WorkspaceId}; + +use crate::auto_refresh::AutoRefresh; +use crate::oidc_refresher::{OidcProvider, OidcRefresher}; +use crate::token_store::{NoStore, TokenStore}; +use crate::transport::{self, SharedTransport}; +use crate::HttpTransport; +use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken}; + +/// An [`AuthStrategy`] that federates a third-party OIDC JWT (Clerk, Supabase, +/// Auth0, …) into a CipherStash CTS service token via `POST /api/authorise`. +/// +/// Each call to [`get_token`](AuthStrategy::get_token) returns a cached CTS +/// token until it expires. Because `/api/authorise` issues no CTS refresh +/// token, renewal means *re-federating*: the strategy calls the +/// [`OidcProvider`] again for a current third-party JWT and exchanges it for a +/// fresh CTS token. Supply an `OidcProvider` that returns the live provider +/// token each time (e.g. wrapping `clerk.session.getToken()`). +/// +/// The strategy is bound to a workspace CRN at construction. The region is +/// derived from the CRN — there is no separate `region` argument — so a +/// caller can't accidentally point the strategy at one region while the +/// CRN says another, matching +/// [`AccessKeyStrategy`](crate::AccessKeyStrategy). +/// +/// Every returned token is checked against the CRN's workspace — the +/// same post-auth verification `AccessKeyStrategy` performs — so a token CTS +/// minted for a different workspace (or one loaded from a poisoned shared +/// cache) is never handed back. Verification can fail in two ways: +/// +/// - [`AuthError::WorkspaceMismatch`] — the JWT decoded cleanly but its +/// `workspace` claim doesn't match the CRN's workspace ID. +/// - [`AuthError::InvalidToken`] — the JWT is malformed or missing the +/// `workspace` claim entirely, so verification can't run. +/// +/// When constructed via [`OidcFederationStrategyBuilder::with_token_store`], the strategy +/// also persists tokens through an external [`TokenStore`] so short-lived +/// instances (e.g. one per Edge Function request) can share a cache and skip +/// re-federating on every cold start. The workspace check runs on cached and +/// store-loaded tokens too, not just freshly federated ones. +/// +/// # Example +/// +/// ```no_run +/// use stack_auth::{AuthError, OidcProviderFn, OidcFederationStrategy, SecretToken}; +/// use cts_common::Crn; +/// +/// let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); +/// let provider = OidcProviderFn::new(|| async { +/// // Real consumers call into a provider SDK / FFI to fetch a live JWT. +/// Ok::<_, AuthError>(SecretToken::new("header.payload.signature".to_string())) +/// }); +/// let strategy = OidcFederationStrategy::new(crn, provider).unwrap(); +/// ``` +pub struct OidcFederationStrategy<P, S = NoStore> { + inner: AutoRefresh<OidcRefresher<P>, S>, + expected_workspace: WorkspaceId, +} + +impl<P: OidcProvider> OidcFederationStrategy<P> { + /// Create a new `OidcFederationStrategy` for the given workspace CRN and + /// OIDC provider. + /// + /// The auth endpoint is resolved automatically via service discovery + /// using the region encoded in the CRN; the workspace ID is used to + /// verify every federated token belongs to the right workspace. + /// + /// A CRN with a `service_name` component (e.g. + /// `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY:zerokms`) is accepted; the + /// `service_name` is ignored. Only the region and workspace ID are + /// load-bearing for this strategy. + pub fn new(workspace_crn: Crn, oidc_provider: P) -> Result<Self, AuthError> { + Self::builder(workspace_crn, oidc_provider).build() + } + + /// Return a builder for configuring an `OidcFederationStrategy` before construction. + pub fn builder(workspace_crn: Crn, oidc_provider: P) -> OidcFederationStrategyBuilder<P> { + OidcFederationStrategyBuilder { + workspace_crn, + oidc_provider, + base_url_override: None, + token_store: NoStore, + transport: None, + } + } +} + +impl<P: OidcProvider, S: TokenStore> AuthStrategy for &OidcFederationStrategy<P, S> { + async fn get_token(self) -> Result<ServiceToken, AuthError> { + self.inner + .get_token() + .await? + .verify_workspace(self.expected_workspace) + } +} + +/// Builder for [`OidcFederationStrategy`]. +/// +/// Created via [`OidcFederationStrategy::builder`]. +pub struct OidcFederationStrategyBuilder<P, S = NoStore> { + workspace_crn: Crn, + oidc_provider: P, + base_url_override: Option<url::Url>, + token_store: S, + transport: Option<SharedTransport>, +} + +impl<P, S> OidcFederationStrategyBuilder<P, S> { + /// Send this strategy's requests through `transport` instead of the + /// bundled `reqwest` client. + /// + /// Without the `http` feature there is no bundled client, so this is + /// required; with it, this is how a host with its own HTTP stack (or a + /// test with a stub) takes over the wire without changing anything else + /// about the strategy. + pub fn transport(mut self, transport: impl HttpTransport) -> Self { + self.transport = Some(transport::share(transport)); + self + } + /// Override the base URL resolved by service discovery. + /// + /// Takes precedence over both the `CS_CTS_HOST` environment variable and + /// region-derived service discovery. Use this to point a single strategy + /// instance at a specific CTS host — e.g. a self-hosted CTS, or a local + /// mock auth server in development — without relying on the process-wide + /// `CS_CTS_HOST`, which would also redirect any other CTS client (e.g. the + /// `protect-ffi` encryption client) sharing the same process. + pub fn base_url(mut self, url: url::Url) -> Self { + self.base_url_override = Some(url); + self + } + + /// Apply an optional base-URL override supplied as a raw string. + /// + /// The string-typed convenience the language bindings (napi, wasm) call, + /// so the "empty means absent, otherwise parse-or-reject" semantics live in + /// one place rather than being re-derived per binding. An absent or empty + /// string is a no-op — base-URL resolution falls back to `CS_CTS_HOST` / + /// region service discovery (see [`build`](Self::build)); a non-empty but + /// malformed string is rejected as [`AuthError::InvalidUrl`]. For an + /// already-parsed URL, use [`base_url`](Self::base_url). + pub fn maybe_base_url(self, base_url: Option<String>) -> Result<Self, AuthError> { + match base_url { + Some(s) if !s.is_empty() => Ok(self.base_url(s.parse::<url::Url>()?)), + _ => Ok(self), + } + } + + /// Wire an external [`TokenStore`] into the strategy. + /// + /// On every call to [`get_token`](AuthStrategy::get_token), if no token is + /// cached in memory, the store is consulted before falling back to + /// re-federating. After every successful federation the new token is + /// written back to the store. Use this from short-lived strategy instances + /// (Edge Functions, Workers) to share a service-token cache across + /// processes — e.g. an HTTP-only cookie. + /// + /// Returns a new builder with the store type erased into the chain — see + /// [`InMemoryTokenStore`](crate::InMemoryTokenStore) and + /// [`TokenStoreFn`](crate::TokenStoreFn) for ready-made implementations. + pub fn with_token_store<T: TokenStore>(self, store: T) -> OidcFederationStrategyBuilder<P, T> { + OidcFederationStrategyBuilder { + workspace_crn: self.workspace_crn, + oidc_provider: self.oidc_provider, + base_url_override: self.base_url_override, + token_store: store, + transport: self.transport, + } + } +} + +impl<P: OidcProvider, S: TokenStore> OidcFederationStrategyBuilder<P, S> { + /// Build the [`OidcFederationStrategy`]. + /// + /// Resolves the base URL in priority order: an explicit [`base_url`] + /// override, then the `CS_CTS_HOST` environment variable, then service + /// discovery using the CRN's region. + /// + /// [`base_url`]: Self::base_url + pub fn build(self) -> Result<OidcFederationStrategy<P, S>, AuthError> { + let expected_workspace = self.workspace_crn.workspace_id; + let region = self.workspace_crn.region; + let base_url = match self.base_url_override { + Some(url) => url, + None => { + crate::cts_base_url_from_env()?.unwrap_or(CtsServiceDiscovery::endpoint(region)?) + } + }; + let refresher = OidcRefresher::new( + self.oidc_provider, + expected_workspace, + ensure_trailing_slash(base_url), + transport::resolve(self.transport)?, + ); + Ok(OidcFederationStrategy { + inner: AutoRefresh::with_store(refresher, self.token_store), + expected_workspace, + }) + } +} + +#[cfg(test)] +#[cfg(feature = "http")] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use std::sync::Arc; + use std::time::{SystemTime, UNIX_EPOCH}; + + use mocktail::prelude::*; + + use super::*; + use crate::oidc_refresher::OidcProviderFn; + use crate::test_support::{crn_with_workspace, jwt_with_workspace}; + use crate::{InMemoryTokenStore, SecretToken, Token, TokenStore}; + + /// A mock CTS that federates any OIDC token into a CTS token carrying the + /// given `workspace` claim. + async fn start_mock_server_returning_jwt(workspace: &str) -> MockServer { + let mut mocks = MockSet::new(); + let jwt = jwt_with_workspace(workspace); + mocks.mock(move |when, then| { + when.post().path("/api/authorise"); + then.json(serde_json::json!({ "accessToken": jwt, "expiry": 3600 })); + }); + let server = + MockServer::new_http("oidc-federation-strategy-workspace-test").with_mocks(mocks); + server.start().await.expect("mock server start"); + server + } + + fn provider() -> OidcProviderFn<impl Fn() -> std::future::Ready<Result<SecretToken, AuthError>>> + { + OidcProviderFn::new(|| { + std::future::ready(Ok(SecretToken::new("header.payload.signature".to_string()))) + }) + } + + const WS: &str = "ZVATKW3VHMFG27DY"; + + /// `maybe_base_url` is the string-typed override seam the language bindings + /// rely on; pin its empty/absent/valid/malformed semantics here so the napi + /// and wasm crates don't each re-test (and risk re-deriving) them. + mod maybe_base_url { + use super::*; + + #[test] + fn absent_is_a_noop() { + let b = OidcFederationStrategy::builder(crn_with_workspace(WS), provider()) + .maybe_base_url(None) + .unwrap(); + assert!(b.base_url_override.is_none()); + } + + #[test] + fn empty_string_is_a_noop() { + let b = OidcFederationStrategy::builder(crn_with_workspace(WS), provider()) + .maybe_base_url(Some(String::new())) + .unwrap(); + assert!(b.base_url_override.is_none()); + } + + #[test] + fn valid_url_sets_the_override() { + let b = OidcFederationStrategy::builder(crn_with_workspace(WS), provider()) + .maybe_base_url(Some("https://cts.example.com".to_string())) + .unwrap(); + assert_eq!( + b.base_url_override.as_ref().map(url::Url::as_str), + Some("https://cts.example.com/") + ); + } + + #[test] + fn malformed_url_is_invalid_url() { + // The builder isn't `Debug`, so match on the result rather than + // `unwrap_err()` (which would require `T: Debug`). + match OidcFederationStrategy::builder(crn_with_workspace(WS), provider()) + .maybe_base_url(Some("not a url".to_string())) + { + Err(AuthError::InvalidUrl(_)) => {} + Ok(_) => panic!("expected Err(InvalidUrl), got Ok"), + Err(other) => panic!("expected InvalidUrl, got: {other:?}"), + } + } + } + + /// Precedence: an explicit `base_url` override (the one `maybe_base_url` + /// sets) wins over the `CS_CTS_HOST` environment variable. `build()` + /// resolves the host in priority order override → `CS_CTS_HOST` → + /// discovery, so with `CS_CTS_HOST` pointed at a dead address the strategy + /// must still federate against the override's mock — proving the env var + /// was not consulted. + /// + /// `CS_CTS_HOST` is read inside `build()` (not `get_token`), so the env + /// override is scoped to just that synchronous call via `temp_env`; the + /// async federation runs with the environment already restored. No other + /// test in this crate reads `CS_CTS_HOST` (every strategy test pins + /// `base_url`), so this can't perturb a concurrent test. + #[tokio::test] + async fn base_url_override_takes_precedence_over_cs_cts_host() { + const WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(WS).await; + + // A routable-but-dead host: if `CS_CTS_HOST` were consulted, federation + // would target this and fail rather than hitting the mock. + let strategy = temp_env::with_var("CS_CTS_HOST", Some("http://127.0.0.1:1/"), || { + OidcFederationStrategy::builder(crn_with_workspace(WS), provider()) + .maybe_base_url(Some(server.url("").to_string())) + .expect("override URL parses") + .build() + .expect("builder") + }); + + let token = (&strategy) + .get_token() + .await + .expect("override must win: federation should hit the mock, not CS_CTS_HOST"); + assert_eq!( + token.workspace_id().expect("workspace_id").as_str(), + WS, + "token should come from the override's mock server", + ); + } + + /// Happy path — the federated token's `workspace` claim matches the + /// configured workspace: `get_token()` returns the token cleanly. + #[tokio::test] + async fn returns_token_when_workspace_matches() { + const WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(WS).await; + + let strategy = OidcFederationStrategy::builder(crn_with_workspace(WS), provider()) + .base_url(server.url("")) + .build() + .expect("builder"); + + let token = (&strategy).get_token().await.expect("get_token"); + assert_eq!( + token.workspace_id().expect("workspace_id").as_str(), + WS, + "happy-path token should carry the expected workspace", + ); + } + + /// A CRN carrying a `service_name` component is accepted; the + /// `service_name` is ignored, exactly as for + /// [`AccessKeyStrategy`](crate::AccessKeyStrategy). Pinned as a test — + /// matching `access_key_strategy::accepts_crn_with_service_name` — so a + /// future contributor doesn't tighten the constructor into rejecting these + /// CRNs without realising the docstring already promises acceptance. + #[tokio::test] + async fn accepts_crn_with_service_name() { + const WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(WS).await; + let crn: Crn = format!("crn:ap-southeast-2.aws:{WS}:zerokms") + .parse() + .expect("CRN with service_name parses"); + + let strategy = OidcFederationStrategy::builder(crn, provider()) + .base_url(server.url("")) + .build() + .expect("CRN with service_name should construct a strategy"); + + let token = (&strategy).get_token().await.expect("get_token"); + assert_eq!( + token.workspace_id().expect("workspace_id").as_str(), + WS, + "service_name is ignored — verification still uses the workspace ID", + ); + } + + /// Mismatch — CTS federates the OIDC token into a CTS token for a + /// *different* workspace than the strategy was configured for. This is the + /// security-critical case: the OIDC provider could be authenticated for a + /// workspace the caller didn't intend. `get_token()` must return + /// `WorkspaceMismatch`, not the token. + #[tokio::test] + async fn errors_when_token_workspace_differs() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(TOKEN_WS).await; + + let strategy = OidcFederationStrategy::builder(crn_with_workspace(EXPECTED_WS), provider()) + .base_url(server.url("")) + .build() + .expect("builder"); + + let err = (&strategy) + .get_token() + .await + .expect_err("expected mismatch"); + match err { + AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch { + expected_workspace, + token_workspace, + }) => { + assert_eq!(expected_workspace.as_str(), EXPECTED_WS); + assert_eq!(token_workspace.as_str(), TOKEN_WS); + } + other => panic!("expected WorkspaceMismatch, got {other:?}"), + } + } + + /// A malformed CTS token (not a JWT) can't be decoded, so verification + /// can't run — `get_token()` surfaces `InvalidToken` rather than handing + /// back an unverifiable token. + #[tokio::test] + async fn errors_with_invalid_token_when_jwt_malformed() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(serde_json::json!({ "accessToken": "not-a-jwt", "expiry": 3600 })); + }); + let server = + MockServer::new_http("oidc-federation-strategy-malformed-test").with_mocks(mocks); + server.start().await.expect("mock server start"); + + let strategy = + OidcFederationStrategy::builder(crn_with_workspace("ZVATKW3VHMFG27DY"), provider()) + .base_url(server.url("")) + .build() + .expect("builder"); + + let err = (&strategy) + .get_token() + .await + .expect_err("expected invalid-token error"); + assert!( + matches!(err, AuthError::InvalidToken(_)), + "expected InvalidToken, got {err:?}", + ); + } + + /// A pre-populated [`TokenStore`] returning a token for a *different* + /// workspace must still be rejected by the strategy wrapper — the same + /// poisoned-shared-cache interaction `AccessKeyStrategy` guards against. + /// A 500-returning mock fails the test loudly if the strategy ever + /// re-federates instead of trusting (and rejecting) the stored token. + #[tokio::test] + async fn rejects_stored_token_for_different_workspace() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY"; + + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "store must satisfy the request"})); + }); + let server = + MockServer::new_http("oidc-federation-strategy-store-mismatch-test").with_mocks(mocks); + server.start().await.expect("mock server start"); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_secs(); + let stored = Token { + access_token: SecretToken::new(jwt_with_workspace(TOKEN_WS)), + token_type: "Bearer".to_string(), + expires_at: now + 3600, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + }; + let store = Arc::new(InMemoryTokenStore::new()); + store.save(&stored).await; + + let strategy = OidcFederationStrategy::builder(crn_with_workspace(EXPECTED_WS), provider()) + .base_url(server.url("")) + .with_token_store(Arc::clone(&store)) + .build() + .expect("builder"); + + let err = (&strategy) + .get_token() + .await + .expect_err("expected mismatch from stored token"); + assert!( + matches!(err, AuthError::WorkspaceMismatch { .. }), + "expected WorkspaceMismatch, got {err:?}", + ); + } + + /// Regression guard — the workspace check runs on *every* `get_token()` + /// call, not only the one that triggers initial federation. A future + /// optimisation that cached the "verified" verdict would let a mismatched + /// token slide through on the second call. + #[tokio::test] + async fn errors_on_each_subsequent_get_token_call() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(TOKEN_WS).await; + + let strategy = OidcFederationStrategy::builder(crn_with_workspace(EXPECTED_WS), provider()) + .base_url(server.url("")) + .build() + .expect("builder"); + + for call in 1..=2 { + let err = match (&strategy).get_token().await { + Ok(_) => panic!("call {call}: expected Err, got Ok"), + Err(e) => e, + }; + assert!( + matches!(err, AuthError::WorkspaceMismatch { .. }), + "call {call}: expected WorkspaceMismatch, got {err:?}", + ); + } + } +} diff --git a/packages/stack-auth/src/oidc_refresher.rs b/packages/stack-auth/src/oidc_refresher.rs new file mode 100644 index 000000000..73f9acb77 --- /dev/null +++ b/packages/stack-auth/src/oidc_refresher.rs @@ -0,0 +1,558 @@ +use std::future::Future; + +use cts_common::WorkspaceId; +use url::Url; + +use crate::authorize_dto::AuthoriseResponse; +use crate::refresher::Refresher; +use crate::transport::{self, SharedTransport}; +use crate::{AuthError, SecretToken, Token}; + +/// Asynchronously supplies the *current* third-party OIDC JWT to federate. +/// +/// [`OidcFederationStrategy`](crate::OidcFederationStrategy) re-invokes this on every refresh: +/// `/api/authorise` issues no CTS refresh token, so renewing an expired CTS +/// token means re-federating with a fresh provider JWT. Implementations +/// typically wrap a provider SDK call (`clerk.session.getToken()`, +/// `supabase.auth.getSession()`), an FFI callback, or a test double. +/// +/// On native targets the trait carries `Send + Sync` bounds so the provider +/// can be driven from `tokio::spawn` background work. On wasm32 the bounds +/// are dropped — reqwest's fetch-backed futures are not `Send` and edge +/// runtimes are single-threaded anyway. +#[cfg(not(target_arch = "wasm32"))] +pub trait OidcProvider: Send + Sync { + /// Fetch the current third-party OIDC JWT to federate. + fn fetch(&self) -> impl Future<Output = Result<SecretToken, AuthError>> + Send; +} + +/// Wasm32 variant of [`OidcProvider`] — drops the `Send + Sync` bounds. +#[cfg(target_arch = "wasm32")] +pub trait OidcProvider { + /// Fetch the current third-party OIDC JWT to federate. + fn fetch(&self) -> impl Future<Output = Result<SecretToken, AuthError>>; +} + +/// [`OidcProvider`] backed by a user-supplied async closure. +/// +/// The closure fires on every federation — initial auth and every +/// re-federation after expiry — so it must return the *current* JWT each +/// time, not a value captured once. The point of the closure is to defer to +/// the provider's own session machinery on every call: a provider SDK +/// (`clerk.session.getToken()`, `supabase.auth.getSession()`) hands back a +/// freshly-minted short-lived JWT, transparently refreshing its own session +/// as needed. Capturing a single token up front would instead pin a JWT that +/// expires and can never be renewed. +/// +/// # Example +/// +/// ```no_run +/// use stack_auth::{AuthError, OidcProviderFn, SecretToken}; +/// +/// # async fn clerk_session_get_token() -> Result<String, AuthError> { Ok(String::new()) } +/// // Each call asks the provider SDK for the *current* session token, so an +/// // expired JWT is refreshed upstream rather than reused. +/// let provider = OidcProviderFn::new(|| async { +/// let jwt = clerk_session_get_token().await?; +/// Ok::<_, AuthError>(SecretToken::new(jwt)) +/// }); +/// ``` +pub struct OidcProviderFn<F> { + fetch: F, +} + +impl<F> OidcProviderFn<F> { + /// Build an `OidcProviderFn` from an async closure returning the current JWT. + pub fn new(fetch: F) -> Self { + Self { fetch } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl<F, Fut> OidcProvider for OidcProviderFn<F> +where + F: Fn() -> Fut + Send + Sync, + Fut: Future<Output = Result<SecretToken, AuthError>> + Send, +{ + fn fetch(&self) -> impl Future<Output = Result<SecretToken, AuthError>> + Send { + (self.fetch)() + } +} + +#[cfg(target_arch = "wasm32")] +impl<F, Fut> OidcProvider for OidcProviderFn<F> +where + F: Fn() -> Fut, + Fut: Future<Output = Result<SecretToken, AuthError>>, +{ + fn fetch(&self) -> impl Future<Output = Result<SecretToken, AuthError>> { + (self.fetch)() + } +} + +/// A [`Refresher`] that federates a third-party OIDC JWT into a CTS service +/// token via `POST /api/authorise`. +/// +/// *Our* federation step is stateless: the credential is always available (the +/// [`OidcProvider`] is re-callable), so `try_credential` returns `Some(())` and +/// `restore` is a no-op — exactly like +/// [`AccessKeyRefresher`](crate::access_key_refresher). The *upstream* OIDC +/// provider that issues the JWT is typically not stateless — it usually relies +/// on its own session machinery (cookies, a session store, a refresh token) +/// to mint the short-lived JWT that [`OidcProvider::fetch`] returns. +/// +/// `/api/authorise` issues no CTS refresh token. Keeping the federated CTS +/// token fresh is therefore the upstream caller's responsibility, via whatever +/// mechanism the provider requires: when the CTS token expires, `AutoRefresh` +/// renews it by calling `refresh` again, which re-invokes the `OidcProvider` +/// for a current JWT — so a provider that hands back an expired or stale JWT +/// will produce an expired or stale CTS token in turn. +pub(crate) struct OidcRefresher<P> { + oidc_provider: P, + workspace_id: WorkspaceId, + base_url: Url, + transport: SharedTransport, +} + +impl<P> OidcRefresher<P> { + pub(crate) fn new( + oidc_provider: P, + workspace_id: WorkspaceId, + base_url: Url, + transport: SharedTransport, + ) -> Self { + Self { + oidc_provider, + workspace_id, + base_url, + transport, + } + } +} + +impl<P: OidcProvider> Refresher for OidcRefresher<P> { + type Credential = (); + + fn save(&self, _token: &Token) { + // Federated tokens are ephemeral — no per-refresher persistence. + } + + fn try_credential(&self, _token: Option<&mut Token>) -> Option<Self::Credential> { + // The OIDC provider is always re-callable, so federation can always be + // attempted — including on cold start (initial auth). + Some(()) + } + + fn restore(&self, _token: &mut Token, _credential: Self::Credential) { + // Nothing to restore — the OIDC provider is re-callable. + } + + async fn refresh(&self, _credential: &Self::Credential) -> Result<Token, AuthError> { + let oidc_token = self.oidc_provider.fetch().await?; + + let url = self.base_url.join("api/authorise")?; + tracing::debug!(url = %url, "federating OIDC token"); + + let resp = transport::post_json( + &self.transport, + url, + &OidcAuthoriseRequest { + oidc_token: oidc_token.as_str(), + workspace_id: self.workspace_id.as_str(), + }, + ) + .await?; + + if !resp.is_success() { + let status = resp.status(); + let body = resp.text(); + tracing::debug!(%status, %body, "OIDC federation failed"); + if let Some(err) = crate::error::classify_issuance_failure(status, &body) { + return Err(err); + } + return Err(AuthError::Server(crate::error::ServerError(format!( + "{status}: {body}" + )))); + } + + let auth_resp: AuthoriseResponse = resp.json()?; + + // The response → Token mapping (including the absolute-epoch `expiry` + // handling that CIP-3233 fixed) lives on `From<AuthoriseResponse>`. + Ok(auth_resp.into()) + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct OidcAuthoriseRequest<'a> { + oidc_token: &'a str, + workspace_id: &'a str, +} + +#[cfg(test)] +#[cfg(feature = "http")] +#[allow(clippy::unwrap_used)] +mod tests { + use crate::transport::default_transport; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::{SystemTime, UNIX_EPOCH}; + + use mocktail::prelude::*; + + use super::*; + use crate::auto_refresh::{AutoRefresh, AutoRefreshError}; + use crate::TokenStore; + + const WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY"; + + fn workspace_id() -> WorkspaceId { + WORKSPACE_ID.parse().unwrap() + } + + /// Build a mock `/api/authorise` response. CTS returns `expiry` as an + /// ABSOLUTE Unix epoch (the JWT `exp` claim), so model that faithfully: the + /// token is valid for `expires_in_secs` from now. + fn auth_response_json(access: &str, expires_in_secs: u64) -> serde_json::Value { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + serde_json::json!({ + "accessToken": access, + "expiry": now + expires_in_secs + }) + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("oidc-refresher-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + /// A [`OidcProvider`] test double that counts invocations and returns a + /// distinct JWT each call (`jwt-0`, `jwt-1`, …). + fn counting_provider() -> (Arc<AtomicUsize>, impl OidcProvider) { + let calls = Arc::new(AtomicUsize::new(0)); + let calls_clone = Arc::clone(&calls); + let provider = OidcProviderFn::new(move || { + let calls = Arc::clone(&calls_clone); + async move { + let n = calls.fetch_add(1, Ordering::SeqCst); + Ok(SecretToken::new(format!("jwt-{n}"))) + } + }); + (calls, provider) + } + + fn make_strategy<P: OidcProvider>( + server: &MockServer, + provider: P, + ) -> AutoRefresh<OidcRefresher<P>> { + let refresher = OidcRefresher::new( + provider, + workspace_id(), + server.url(""), + default_transport(), + ); + AutoRefresh::with_store(refresher, crate::NoStore) + } + + fn make_token(access: &str, expires_in_secs: u64) -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + Token { + access_token: SecretToken::new(access), + token_type: "Bearer".to_string(), + expires_at: now + expires_in_secs, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + } + + // ---- Regression: CTS `expiry` is an absolute epoch (CIP-3233) ---- + + /// CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (the JWT + /// `exp` claim), not a relative duration — identical to the access-key path + /// fixed in CIP-3233. The OIDC refresher must use it as-is. + /// + /// Pre-fix (`expires_at = now + expiry`), this token's `expires_at` lands + /// ~decades in the future, so `is_expired()` is never true — the federated + /// token never re-federates and silently dies at its real ~15-minute `exp`. + /// The assertion below fails under the pre-fix arithmetic (`expires_in()` ≈ + /// 1.7e9) and passes with the fix (`expires_in()` ≈ 900). See CIP-3233. + #[tokio::test] + async fn oidc_expiry_is_absolute_epoch_not_relative() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let absolute_expiry = now + 900; // a 15-minute token, as an absolute epoch + + let mut mocks = MockSet::new(); + mocks.mock(move |when, then| { + when.post().path("/api/authorise"); + then.json(serde_json::json!({ + "accessToken": "tok", + "expiry": absolute_expiry + })); + }); + let server = start_server(mocks).await; + + let (_calls, provider) = counting_provider(); + let refresher = OidcRefresher::new( + provider, + workspace_id(), + server.url(""), + default_transport(), + ); + let token = refresher.refresh(&()).await.unwrap(); + + assert!( + token.expires_in() <= 1000, + "expires_in should be ~900s (absolute `expiry` used as-is); got {} \ + — pre-fix `now + expiry` yields ~1.7e9", + token.expires_in() + ); + assert!( + !token.is_expired(), + "a freshly federated 15-minute token must not be reported as already expired" + ); + } + + #[tokio::test] + async fn test_initial_federation() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("cts-token", 3600)); + }); + let server = start_server(mocks).await; + let (calls, provider) = counting_provider(); + let strategy = make_strategy(&server, provider); + + let token = strategy.get_token().await.unwrap(); + + assert_eq!(token.as_str(), "cts-token"); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "initial federation should invoke the OIDC provider once" + ); + } + + #[test] + fn test_request_serialization() { + let body = serde_json::to_value(OidcAuthoriseRequest { + oidc_token: "the-jwt", + workspace_id: WORKSPACE_ID, + }) + .unwrap(); + assert_eq!( + body, + serde_json::json!({ "oidcToken": "the-jwt", "workspaceId": WORKSPACE_ID }), + "request body should carry exactly the OIDC token and workspace ID" + ); + } + + #[tokio::test] + async fn test_caches_token_after_initial_federation() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("cts-token", 3600)); + }); + let server = start_server(mocks).await; + let (calls, provider) = counting_provider(); + let strategy = make_strategy(&server, provider); + + assert_eq!(strategy.get_token().await.unwrap().as_str(), "cts-token"); + + // Replace the mock so a second federation call would fail loudly. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "should not be called"})); + }); + + assert_eq!(strategy.get_token().await.unwrap().as_str(), "cts-token"); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "cached token should be returned without re-federating" + ); + } + + #[tokio::test] + async fn test_re_federates_on_expiry() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("re-federated-token", 3600)); + }); + let server = start_server(mocks).await; + + let (calls, provider) = counting_provider(); + // Pre-seed the store with an already-expired token. + let store = Arc::new(crate::InMemoryTokenStore::new()); + store.save(&make_token("stale-cts-token", 0)).await; + + let refresher = OidcRefresher::new( + provider, + workspace_id(), + server.url(""), + default_transport(), + ); + let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); + + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "re-federated-token", + "expired cached token should trigger re-federation" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "re-federation should invoke the OIDC provider for a current JWT" + ); + } + + #[tokio::test] + async fn test_oidc_provider_failure_propagates() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("unreachable", 3600)); + }); + let server = start_server(mocks).await; + + let provider = OidcProviderFn::new(|| async { + Err::<SecretToken, _>(AuthError::Server(crate::error::ServerError( + "provider exploded".to_string(), + ))) + }); + let strategy = make_strategy(&server, provider); + + let err = strategy.get_token().await.unwrap_err(); + assert!( + matches!(err, AutoRefreshError::Auth(AuthError::Server(_))), + "OIDC provider failure should surface as an auth error, got: {err:?}" + ); + } + + #[tokio::test] + async fn test_server_rejection_propagates() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "workspace mismatch"})); + }); + let server = start_server(mocks).await; + let (_calls, provider) = counting_provider(); + let strategy = make_strategy(&server, provider); + + let err = strategy.get_token().await.unwrap_err(); + assert!( + matches!(err, AutoRefreshError::Auth(AuthError::Server(_))), + "a 500 from /api/authorise should surface as a server error, got: {err:?}" + ); + } + + /// The OIDC federation path is the third caller of + /// `classify_issuance_failure`. The other two are covered; without this + /// the claim that all three cannot drift apart is untested here. + #[tokio::test] + async fn usage_limit_402_is_typed_not_server_error() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.status(reqwest::StatusCode::PAYMENT_REQUIRED) + .json(serde_json::json!({ + "error": "access_denied", + "cs_code": "USAGE_LIMIT_EXCEEDED", + "error_description": "Workspace has exceeded its usage limit", + })); + }); + let server = start_server(mocks).await; + let (_calls, provider) = counting_provider(); + let strategy = make_strategy(&server, provider); + + let AutoRefreshError::Auth(err) = strategy.get_token().await.unwrap_err() else { + panic!("expected a typed auth error"); + }; + + assert_eq!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "a usage limit must not be flattened into SERVER_ERROR", + ); + } + + #[tokio::test] + async fn test_loads_token_from_store_on_cold_start_no_http() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "should not be called"})); + }); + let server = start_server(mocks).await; + + let store = Arc::new(crate::InMemoryTokenStore::new()); + store.save(&make_token("from-store", 3600)).await; + + let (calls, provider) = counting_provider(); + let refresher = OidcRefresher::new( + provider, + workspace_id(), + server.url(""), + default_transport(), + ); + let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); + + let token = strategy.get_token().await.unwrap(); + assert_eq!(token.as_str(), "from-store"); + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "a fresh cached token should be used without invoking the OIDC provider" + ); + } + + #[tokio::test] + async fn test_persists_token_to_store_after_federation() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("freshly-federated", 3600)); + }); + let server = start_server(mocks).await; + + let store = Arc::new(crate::InMemoryTokenStore::new()); + let (_calls, provider) = counting_provider(); + let refresher = OidcRefresher::new( + provider, + workspace_id(), + server.url(""), + default_transport(), + ); + let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); + + let token = strategy.get_token().await.unwrap(); + assert_eq!(token.as_str(), "freshly-federated"); + + let saved = store + .load() + .await + .expect("store should hold a token after federation"); + assert_eq!(saved.access_token().as_str(), "freshly-federated"); + } +} diff --git a/packages/stack-auth/src/refresher.rs b/packages/stack-auth/src/refresher.rs new file mode 100644 index 000000000..d2e2844d2 --- /dev/null +++ b/packages/stack-auth/src/refresher.rs @@ -0,0 +1,84 @@ +use std::future::Future; + +use crate::{AuthError, Token}; + +/// Internal trait defining how to refresh or re-authenticate to obtain a new [`Token`]. +/// +/// [`AutoRefresh<R>`](crate::auto_refresh::AutoRefresh) delegates the type-specific +/// parts of token refresh to the `Refresher` implementation while handling the +/// concurrency orchestration (cascade prevention, two-tier locking) generically. +/// +/// On native targets the trait carries `Send + Sync` bounds so refreshers can +/// drive `tokio::spawn` background work. On wasm32 the bounds are dropped — +/// reqwest's fetch-backed futures are not `Send` (they reference JS handles +/// via `Rc<RefCell<...>>`) and edge runtimes are single-threaded anyway. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) trait Refresher: Send + Sync { + /// The credential extracted from the current token before a refresh attempt. + type Credential: Send; + + /// Persist a token after a successful refresh. Best-effort — implementations + /// should log on failure rather than returning an error. + fn save(&self, token: &Token); + + /// Extract a credential for refreshing. + /// + /// `token` is `None` on cold start (no cached token). Returns `None` if + /// this refresher can't produce a token without a prior one (e.g. OAuth + /// needs a refresh token). + fn try_credential(&self, token: Option<&mut Token>) -> Option<Self::Credential>; + + /// Restore state after a failed refresh attempt (e.g. put the refresh token + /// back so the next caller can retry). + fn restore(&self, token: &mut Token, credential: Self::Credential); + + /// Perform the HTTP refresh or authentication call. + /// + /// Return [`AuthError::Store`] only when the exchange succeeded but its + /// result could not be persisted. `AutoRefresh` treats that credential as + /// spent: it is not restored, and the call fails even if the cached token + /// is still usable. + fn refresh( + &self, + credential: &Self::Credential, + ) -> impl Future<Output = Result<Token, AuthError>> + Send; +} + +#[cfg(target_arch = "wasm32")] +pub(crate) trait Refresher { + /// The credential extracted from the current token before a refresh attempt. + type Credential; + + /// Persist a token after a successful refresh. Best-effort — implementations + /// should log on failure rather than returning an error. + /// + /// On wasm32 persistence is typically a no-op (no filesystem) — token + /// caching is in-memory and short-lived. + fn save(&self, token: &Token); + + /// Extract a credential for refreshing. + /// + /// `token` is `None` on cold start (no cached token). Returns `None` if + /// this refresher can't produce a token without a prior one (e.g. OAuth + /// needs a refresh token). + fn try_credential(&self, token: Option<&mut Token>) -> Option<Self::Credential>; + + /// Restore state after a failed refresh attempt (e.g. put the refresh token + /// back so the next caller can retry). + fn restore(&self, token: &mut Token, credential: Self::Credential); + + /// Perform the HTTP refresh or authentication call. + /// + /// Return [`AuthError::Store`] only when the exchange succeeded but its + /// result could not be persisted. `AutoRefresh` treats that credential as + /// spent: it is not restored, and the call fails even if the cached token + /// is still usable. + /// + /// The returned future is not `Send` — reqwest's wasm32 fetch backend + /// holds JS handles via `Rc<RefCell<...>>` and edge runtimes are + /// single-threaded anyway. + fn refresh( + &self, + credential: &Self::Credential, + ) -> impl Future<Output = Result<Token, AuthError>>; +} diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs new file mode 100644 index 000000000..a822d227a --- /dev/null +++ b/packages/stack-auth/src/service_token.rs @@ -0,0 +1,512 @@ +use cts_common::claims::{ServiceType, Services}; +use cts_common::WorkspaceId; +use url::Url; +use vitaminc::protected::OpaqueDebug; +use zeroize::ZeroizeOnDrop; + +use crate::{AuthError, SecretToken}; + +/// A CipherStash service token returned by an [`AuthStrategy`](crate::AuthStrategy). +/// +/// Wraps a bearer credential ([`SecretToken`]) together with eagerly decoded +/// JWT claims that are used for service discovery. The JWT is decoded (but +/// **not** signature-verified) using [`cts_common::claims::ClientClaims`], so +/// only CipherStash-issued service tokens (from CTS or the access-key exchange) +/// will have their claims resolved. +/// +/// # Decoded claims +/// +/// * `subject()` — the `sub` claim (e.g. `"CS|auth0|user123"`). +/// * `workspace_id()` — the workspace identifier from the token. +/// * `issuer()` — the `iss` URL, i.e. the CTS host for this workspace. +/// * `zerokms_url()` — the ZeroKMS endpoint from the `services` claim. +/// +/// For non-JWT tokens (e.g. static test tokens) or JWTs that don't match +/// the CipherStash claims schema, these methods return +/// `Err(AuthError::InvalidToken)`. +/// +/// # Security +/// +/// Like [`SecretToken`], this is zeroized on drop and hidden from [`Debug`] +/// output. +#[derive(Clone, OpaqueDebug, ZeroizeOnDrop)] +pub struct ServiceToken { + secret: SecretToken, + #[zeroize(skip)] + decoded: Result<DecodedClaims, String>, +} + +#[derive(Clone, Debug)] +struct DecodedClaims { + subject: String, + workspace: WorkspaceId, + issuer: Url, + services: Services, +} + +impl ServiceToken { + /// Create a `ServiceToken` from a [`SecretToken`]. + /// + /// If the token string is a valid JWT with `iss` and `services` claims, + /// they are decoded eagerly. If decoding fails (not a JWT, missing claims, + /// etc.) the token is still usable as a bearer credential — `issuer()` and + /// `zerokms_url()` will simply return an error. + pub fn new(secret: SecretToken) -> Self { + let decoded = Self::try_decode(&secret); + Self { secret, decoded } + } + + /// Expose the inner token string for use as a bearer credential. + pub fn as_str(&self) -> &str { + self.secret.as_str() + } + + /// Return the `sub` (subject) claim from the JWT. + /// + /// In CipherStash tokens the subject encodes the principal identity, + /// e.g. `"CS|auth0|user123"` for a user or `"CS|CSAKkeyId"` for an + /// access key. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or + /// the claims could not be decoded. + pub fn subject(&self) -> Result<&str, AuthError> { + self.decoded + .as_ref() + .map(|d| d.subject.as_str()) + .map_err(|reason| AuthError::InvalidToken(crate::error::InvalidToken(reason.clone()))) + } + + /// Return the workspace identifier from the JWT claims. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or + /// the claims could not be decoded. + pub fn workspace_id(&self) -> Result<&WorkspaceId, AuthError> { + self.decoded + .as_ref() + .map(|d| &d.workspace) + .map_err(|reason| AuthError::InvalidToken(crate::error::InvalidToken(reason.clone()))) + } + + /// Verify the token's `workspace` claim matches `expected`, returning the + /// token unchanged on a match. + /// + /// This is the shared post-auth check that every strategy bound to a + /// workspace CRN ([`AccessKeyStrategy`](crate::AccessKeyStrategy), + /// [`OidcFederationStrategy`](crate::OidcFederationStrategy)) runs on each + /// [`get_token`](crate::AuthStrategy::get_token), so a token CTS minted for + /// a different workspace (or loaded from a poisoned shared cache) is never + /// handed back. + /// + /// # Errors + /// + /// - [`AuthError::WorkspaceMismatch`] if the token's `workspace` claim is a + /// different workspace than `expected`. + /// - [`AuthError::InvalidToken`] if the token is not a valid JWT or its + /// `workspace` claim could not be decoded, so verification can't run. + pub(crate) fn verify_workspace(self, expected: WorkspaceId) -> Result<Self, AuthError> { + let token_workspace = *self.workspace_id()?; + if token_workspace != expected { + return Err(AuthError::WorkspaceMismatch( + crate::error::WorkspaceMismatch { + expected_workspace: expected, + token_workspace, + }, + )); + } + Ok(self) + } + + /// Return the `iss` (issuer) URL from the JWT claims. + /// + /// In CipherStash tokens the issuer is the CTS host URL for the workspace. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or + /// the `iss` claim could not be parsed as a URL. + pub fn issuer(&self) -> Result<&Url, AuthError> { + self.decoded + .as_ref() + .map(|d| &d.issuer) + .map_err(|reason| AuthError::InvalidToken(crate::error::InvalidToken(reason.clone()))) + } + + /// Return the decoded services map from the JWT claims. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or + /// the claims could not be decoded. + pub fn services(&self) -> Result<&Services, AuthError> { + self.decoded + .as_ref() + .map(|d| &d.services) + .map_err(|reason| AuthError::InvalidToken(crate::error::InvalidToken(reason.clone()))) + } + + /// Return the ZeroKMS endpoint URL from the `services` claim. + /// + /// CTS-issued JWTs include a `services` claim containing a map of service + /// type to endpoint URL. This method looks up the `zerokms` entry. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or + /// the `services` claim does not include a ZeroKMS endpoint. + pub fn zerokms_url(&self) -> Result<Url, AuthError> { + self.services()? + .get(ServiceType::ZeroKms) + .cloned() + .ok_or_else(|| { + AuthError::InvalidToken(crate::error::InvalidToken( + "Token does not include a ZeroKMS endpoint in the services claim".into(), + )) + }) + } + + /// Attempt to decode the JWT claims from the token string. + /// + /// NOTE: This does not verify the token signature or validate any claims, + /// it only decodes the claims if the token is a well-formed JWT. + fn try_decode(secret: &SecretToken) -> Result<DecodedClaims, String> { + let claims = decode_claims(secret.as_str())?; + let issuer: Url = claims + .iss + .parse() + .map_err(|e| format!("iss claim is not a valid URL: {e}"))?; + + Ok(DecodedClaims { + subject: claims.sub, + workspace: claims.workspace, + issuer, + services: claims.services, + }) + } +} + +/// Decode the JWT payload into +/// [`ClientClaims`](cts_common::claims::ClientClaims) without verifying the +/// signature — we only read claims from a token we already hold. See +/// [`crate::decode_jwt_payload`] for why we parse by hand. +/// +/// Deliberately *not* `cts_common::claims::Claims`: that is the server's view, +/// where every claim is required so an unauthorised token is rejected. Reading +/// discovery claims out of a token we already hold enforces nothing, so it must +/// not fail over a claim it never reads — `org_id` in particular, whose absence +/// is for ZeroKMS and CTS to reject once they have verified the signature. +fn decode_claims(token_str: &str) -> Result<cts_common::claims::ClientClaims, String> { + // Strip the `AuthError::InvalidToken` prefix — callers re-wrap this string + // in `AuthError::InvalidToken(reason)`, and we don't want "Invalid token: + // Invalid token: ..." in the final message. + crate::decode_jwt_payload(token_str).map_err(|e| match e { + crate::AuthError::InvalidToken(crate::error::InvalidToken(reason)) => reason, + other => other.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn claims_json(iss: &str, services: Option<BTreeMap<&str, &str>>) -> serde_json::Value { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let mut claims = serde_json::json!({ + "iss": iss, + "sub": "CS|test-user", + "aud": "legacy-aud-value", + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "org_id": "org_test_default", + "scope": "", + }); + + if let Some(svc) = services { + claims["services"] = serde_json::to_value(svc).unwrap(); + } + + claims + } + + fn encode_claims(claims: serde_json::Value) -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .unwrap() + } + + fn make_jwt(iss: &str, services: Option<BTreeMap<&str, &str>>) -> String { + encode_claims(claims_json(iss, services)) + } + + /// A JWT carrying every claim [`make_jwt`] mints *except* `org_id` — i.e. a + /// token from a CTS that predates the claim, or one rolled back past it. + fn make_jwt_without_org_id(iss: &str, services: Option<BTreeMap<&str, &str>>) -> String { + let mut claims = claims_json(iss, services); + claims.as_object_mut().unwrap().remove("org_id"); + encode_claims(claims) + } + + fn services_with_zerokms(url: &str) -> Option<BTreeMap<&str, &str>> { + Some(BTreeMap::from([("zerokms", url)])) + } + + #[test] + fn jwt_token_provides_issuer() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt.clone())); + + assert_eq!(token.as_str(), jwt); + assert_eq!(token.issuer().unwrap().as_str(), "https://cts.example.com/"); + } + + #[test] + fn non_jwt_token_returns_errors_with_reason() { + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + + assert_eq!(token.as_str(), "not-a-jwt"); + + let err = token.issuer().unwrap_err().to_string(); + assert!( + err.contains("three segments"), + "expected specific decode error, got: {err}" + ); + } + + #[test] + fn zerokms_url_from_services_claim() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + assert_eq!( + token.zerokms_url().unwrap().as_str(), + "https://zerokms.example.com/" + ); + } + + #[test] + fn zerokms_url_from_services_claim_localhost() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("http://localhost:3002/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + assert_eq!( + token.zerokms_url().unwrap().as_str(), + "http://localhost:3002/" + ); + } + + #[test] + fn zerokms_url_errors_when_services_claim_missing() { + let jwt = make_jwt("https://cts.example.com/", None); + let token = ServiceToken::new(SecretToken::new(jwt)); + let err = token.zerokms_url().unwrap_err().to_string(); + assert!( + err.contains("services claim"), + "expected services claim error, got: {err}" + ); + } + + #[test] + fn zerokms_url_errors_for_non_jwt() { + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + assert!(token.zerokms_url().is_err()); + } + + #[test] + fn services_returns_map_for_valid_jwt() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + let services = token.services().unwrap(); + assert_eq!( + services + .get(cts_common::claims::ServiceType::ZeroKms) + .map(|u| u.as_str()), + Some("https://zerokms.example.com/") + ); + } + + #[test] + fn services_returns_empty_map_when_claim_missing() { + let jwt = make_jwt("https://cts.example.com/", None); + let token = ServiceToken::new(SecretToken::new(jwt)); + let services = token.services().unwrap(); + assert!(services.is_empty()); + } + + #[test] + fn services_errors_for_non_jwt() { + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + let err = token.services().unwrap_err().to_string(); + assert!( + err.contains("three segments"), + "expected specific decode error, got: {err}" + ); + } + + #[test] + fn subject_from_valid_jwt() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + assert_eq!( + token.subject().unwrap(), + "CS|test-user", + "subject should match JWT sub claim" + ); + } + + #[test] + fn subject_errors_for_non_jwt() { + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + assert!( + token.subject().is_err(), + "subject should error for non-JWT token" + ); + } + + #[test] + fn workspace_id_from_valid_jwt() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + assert_eq!( + token.workspace_id().unwrap().to_string(), + "ZVATKW3VHMFG27DY", + "workspace_id should match JWT workspace claim" + ); + } + + #[test] + fn workspace_id_errors_for_non_jwt() { + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + assert!( + token.workspace_id().is_err(), + "workspace_id should error for non-JWT token" + ); + } + + #[test] + fn verify_workspace_returns_token_when_workspace_matches() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + let expected: WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap(); + + let verified = token + .verify_workspace(expected) + .expect("matching workspace should pass verification"); + assert_eq!( + verified.workspace_id().unwrap().to_string(), + "ZVATKW3VHMFG27DY", + "verified token should still carry its workspace claim", + ); + } + + #[test] + fn verify_workspace_errors_with_mismatch_when_workspace_differs() { + // make_jwt mints a token for workspace ZVATKW3VHMFG27DY. + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + let expected: WorkspaceId = "AAAAAAAAAAAAAAAA".parse().unwrap(); + + let err = token + .verify_workspace(expected) + .expect_err("a different expected workspace must be rejected"); + match err { + AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch { + expected_workspace, + token_workspace, + }) => { + assert_eq!(expected_workspace.to_string(), "AAAAAAAAAAAAAAAA"); + assert_eq!(token_workspace.to_string(), "ZVATKW3VHMFG27DY"); + } + other => panic!("expected WorkspaceMismatch, got {other:?}"), + } + } + + #[test] + fn verify_workspace_errors_with_invalid_token_for_non_jwt() { + // A non-JWT can't be decoded, so verification can't run. + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + let expected: WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap(); + + let err = token + .verify_workspace(expected) + .expect_err("a non-JWT token must surface InvalidToken"); + assert!( + matches!(err, AuthError::InvalidToken(_)), + "expected InvalidToken, got {err:?}", + ); + } + + #[test] + fn debug_does_not_leak_secret() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt.clone())); + let debug = format!("{:?}", token); + assert!(!debug.contains(&jwt)); + } + + /// `org_id` is a *server-side* requirement: ZeroKMS and CTS reject a token + /// without it, after verifying the signature. This decode path verifies + /// nothing and reads only `sub`/`workspace`/`iss`/`services`, so a claim it + /// never looks at must not be able to break service discovery — otherwise a + /// CTS rolled back past the commit that started minting `org_id` takes every + /// SDK and CLI down with it, not just billing. + #[test] + fn resolves_discovery_claims_without_org_id() { + let jwt = make_jwt_without_org_id( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + + assert_eq!(token.subject().unwrap(), "CS|test-user"); + assert_eq!( + token.workspace_id().unwrap().to_string(), + "ZVATKW3VHMFG27DY" + ); + assert_eq!(token.issuer().unwrap().as_str(), "https://cts.example.com/"); + assert_eq!( + token.zerokms_url().unwrap().as_str(), + "https://zerokms.example.com/" + ); + } +} diff --git a/packages/stack-auth/src/static_token_strategy.rs b/packages/stack-auth/src/static_token_strategy.rs new file mode 100644 index 000000000..66b86f692 --- /dev/null +++ b/packages/stack-auth/src/static_token_strategy.rs @@ -0,0 +1,30 @@ +use crate::{AuthError, AuthStrategy, SecretToken, ServiceToken}; + +/// A simple [`AuthStrategy`] that always returns a fixed token. +/// +/// Useful in tests where a token has already been obtained (e.g. from a mock auth +/// server or via federation) and just needs to be presented as-is. +/// +/// ``` +/// use stack_auth::{StaticTokenStrategy, AuthStrategy}; +/// +/// # async fn example() { +/// let strategy = StaticTokenStrategy::new("my-token"); +/// let token = (&strategy).get_token().await.unwrap(); +/// assert_eq!(token.as_str(), "my-token"); +/// # } +/// ``` +pub struct StaticTokenStrategy(SecretToken); + +impl StaticTokenStrategy { + /// Create a new `StaticTokenStrategy` wrapping the given token string. + pub fn new(token: impl Into<String>) -> Self { + Self(SecretToken::new(token)) + } +} + +impl AuthStrategy for &StaticTokenStrategy { + async fn get_token(self) -> Result<ServiceToken, AuthError> { + Ok(ServiceToken::new(self.0.clone())) + } +} diff --git a/packages/stack-auth/src/test_support.rs b/packages/stack-auth/src/test_support.rs new file mode 100644 index 000000000..51bf163f2 --- /dev/null +++ b/packages/stack-auth/src/test_support.rs @@ -0,0 +1,101 @@ +//! Shared, crate-internal test helpers for minting [`Token`]s. +//! +//! Several test modules need a token carrying specific JWT claims, or a token +//! with an arbitrary raw access-token string. Keeping one definition here avoids +//! the fixture drift that comes from copy-pasting the `Token { .. }` literal and +//! the unsigned-JWT mint into every test module. + +#[cfg(feature = "http")] +use cts_common::Crn; + +use crate::{SecretToken, Token}; + +/// A [`Token`] with the given raw access-token string and a far-future expiry, +/// so it reads as valid. The access token need not be a JWT — pass any string to +/// exercise the not-a-JWT paths. +pub(crate) fn raw_token(access_token: &str) -> Token { + Token { + access_token: SecretToken::new(access_token), + token_type: "Bearer".to_string(), + expires_at: u64::MAX, + refresh_token: Some(SecretToken::new("refresh-token")), + region: None, + client_id: None, + device_instance_id: None, + } +} + +/// A [`Token`] whose access token is a real (unsigned) JWT carrying `claims`. +pub(crate) fn jwt_token(claims: serde_json::Value) -> Token { + use jsonwebtoken::{encode, EncodingKey, Header}; + let jwt = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .expect("encode JWT"); + raw_token(&jwt) +} + +/// Standard CTS JWT claims for `workspace`, with the other required claims +/// (`iss`/`sub`/`aud`/`iat`/`exp`/`org_id`/`scope`) filled in with valid placeholders. +/// +/// NOTE: `exp` is a fixed *past* epoch, so the token reads as expired — use +/// [`jwt_with_workspace`] instead when a currently-valid token is needed. +pub(crate) fn claims_with_workspace(workspace: &str) -> serde_json::Value { + serde_json::json!({ + "workspace": workspace, + "iss": "https://cts.example.com", + "sub": "user-123", + "aud": "https://cts.example.com", + "iat": 1_700_000_000u64, + "exp": 1_700_003_600u64, + "org_id": "org_test_default", + "scope": "dataset:create", + }) +} + +/// A workspace [`Crn`] in the standard test region (`ap-southeast-2.aws`) +/// carrying the given `workspace` ID. +/// +/// Only the mock-server tests, which need the bundled transport, mint one. +#[cfg(feature = "http")] +pub(crate) fn crn_with_workspace(workspace: &str) -> Crn { + format!("crn:ap-southeast-2.aws:{workspace}") + .parse() + .expect("test CRN parses") +} + +/// A real (unsigned) JWT *string* carrying the given `workspace` claim and a +/// currently-valid (`now + 1h`) expiry — for exercising the post-auth +/// workspace verification that CRN-bound strategies run. Unlike +/// [`claims_with_workspace`], whose `exp` is a fixed past epoch, the token this +/// mints reads as valid. +/// +/// Only the mock-server tests, which need the bundled transport, mint one. +#[cfg(feature = "http")] +pub(crate) fn jwt_with_workspace(workspace: &str) -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_secs(); + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-principal", + "aud": "test-audience", + "iat": now, + "exp": now + 3600, + "workspace": workspace, + "org_id": "org_test_default", + "scope": "", + }); + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .expect("JWT encode") +} diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs new file mode 100644 index 000000000..f66580311 --- /dev/null +++ b/packages/stack-auth/src/token.rs @@ -0,0 +1,852 @@ +use cts_common::claims::ClientClaims; +use cts_common::{Crn, Region, WorkspaceId}; +use url::Url; + +use crate::transport::{self, SharedTransport}; +use crate::{AuthError, SecretToken}; + +impl stack_profile::ProfileData for Token { + const FILENAME: &'static str = "auth.json"; + const MODE: Option<u32> = Some(0o600); +} + +/// How many seconds before expiry [`Token::is_expired`] returns `true`. +/// +/// This leeway triggers preemptive refresh well before the token becomes +/// unusable, giving the HTTP refresh call time to complete while concurrent +/// callers can still use the current token. +const EXPIRY_LEEWAY_SECS: u64 = 90; + +/// The current Unix time in whole seconds, from the system wall clock. +/// +/// Delegates to [`SystemClock`](crate::clock::SystemClock) so the crate has a +/// single definition of "now"; the `*_at` methods take an explicit `now` for +/// tests that drive a [`Clock`](crate::clock::Clock). +fn now_unix_secs() -> u64 { + use crate::clock::{Clock, SystemClock}; + SystemClock.now_unix_secs() +} + +/// An access token returned by a successful authentication flow. +/// +/// The token contains a [`SecretToken`] (the bearer credential), a token type +/// (typically `"Bearer"`), and an absolute expiry timestamp. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct Token { + pub(crate) access_token: SecretToken, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) refresh_token: Option<SecretToken>, + pub(crate) token_type: String, + pub(crate) expires_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) region: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) client_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) device_instance_id: Option<String>, +} + +impl Token { + /// Returns a reference to the access token credential. + /// + /// The returned [`SecretToken`] is opaque — its [`Debug`] output is masked. + /// Pass it to API clients that need the raw bearer token. + pub fn access_token(&self) -> &SecretToken { + &self.access_token + } + + /// The token type (e.g. `"Bearer"`). + pub fn token_type(&self) -> &str { + &self.token_type + } + + /// The absolute epoch timestamp when the token expires. + pub fn expires_at(&self) -> u64 { + self.expires_at + } + + /// How many seconds until the token expires (computed from the current time). + pub fn expires_in(&self) -> u64 { + self.expires_at.saturating_sub(now_unix_secs()) + } + + /// Returns `true` if the token has expired (with 90 seconds of leeway). + /// + /// The 90-second leeway triggers preemptive refresh well before the token + /// becomes unusable, giving the HTTP refresh call plenty of time to complete + /// while the current token is still valid for concurrent callers. + /// + /// For checking whether the token is still usable as a bearer credential, + /// use [`is_usable`](Self::is_usable) instead. + pub fn is_expired(&self) -> bool { + self.is_expired_at(now_unix_secs()) + } + + /// [`is_expired`](Self::is_expired) evaluated against an explicit `now` + /// (seconds since the Unix epoch) rather than the wall clock. + /// + /// Used internally so [`AutoRefresh`](crate::auto_refresh::AutoRefresh) can + /// drive expiry from an injected [`Clock`](crate::clock::Clock). + pub(crate) fn is_expired_at(&self, now: u64) -> bool { + now.saturating_add(EXPIRY_LEEWAY_SECS) >= self.expires_at + } + + /// Returns `true` if the token is still usable (before the actual expiry timestamp). + /// + /// Unlike [`is_expired`](Self::is_expired) which includes 90s leeway for preemptive + /// refresh, this only returns `false` when the token has genuinely expired. + pub fn is_usable(&self) -> bool { + self.is_usable_at(now_unix_secs()) + } + + /// [`is_usable`](Self::is_usable) evaluated against an explicit `now` + /// (seconds since the Unix epoch) rather than the wall clock. + pub(crate) fn is_usable_at(&self, now: u64) -> bool { + now < self.expires_at + } + + /// Returns a reference to the refresh token, if one was provided. + pub fn refresh_token(&self) -> Option<&SecretToken> { + self.refresh_token.as_ref() + } + + /// Takes the refresh token out, leaving `None` in its place. + pub fn take_refresh_token(&mut self) -> Option<SecretToken> { + self.refresh_token.take() + } + + /// Returns the stored region identifier, if any. + pub fn region(&self) -> Option<&str> { + self.region.as_deref() + } + + /// Returns the stored client ID, if any. + pub fn client_id(&self) -> Option<&str> { + self.client_id.as_deref() + } + + /// Set the region identifier on this token. + pub(crate) fn set_region(&mut self, region: impl Into<String>) { + self.region = Some(region.into()); + } + + /// Set the client ID on this token. + pub(crate) fn set_client_id(&mut self, client_id: impl Into<String>) { + self.client_id = Some(client_id.into()); + } + + /// Returns the stored device instance ID, if any. + pub fn device_instance_id(&self) -> Option<&str> { + self.device_instance_id.as_deref() + } + + /// Set the device instance ID on this token. + pub(crate) fn set_device_instance_id(&mut self, id: impl Into<String>) { + self.device_instance_id = Some(id.into()); + } + + /// Returns the workspace ID from the JWT claims. + /// + /// The access token is decoded (without signature verification) to extract + /// the `workspace` claim. + pub fn workspace_id(&self) -> Result<WorkspaceId, AuthError> { + self.decode_claims().map(|c| c.workspace) + } + + /// Returns the workspace CRN derived from the token's region and workspace ID. + /// + /// The region is set during the device code flow, and the workspace ID is + /// extracted from the JWT `workspace` claim. + pub fn workspace_crn(&self) -> Result<Crn, AuthError> { + let workspace_id = self.workspace_id()?; + let region: Region = self + .region() + .ok_or(AuthError::NotAuthenticated(crate::error::NotAuthenticated))? + .parse() + .map_err(|e: cts_common::RegionError| { + AuthError::Server(crate::error::ServerError(e.to_string())) + })?; + Ok(Crn::new(region, workspace_id)) + } + + /// Returns the issuer URL from the JWT claims. + /// + /// The `iss` claim in CipherStash tokens is the CTS host URL for the + /// workspace, so this can be used directly as the CTS base URL. + pub fn issuer(&self) -> Result<Url, AuthError> { + let claims = self.decode_claims()?; + claims.iss.parse().map_err(AuthError::from) + } + + /// Decode the JWT payload into [`ClientClaims`] without verifying the + /// signature. + /// + /// This is safe because we already possess the token — we just need to read + /// the claims it contains. See [`crate::decode_jwt_payload`] for why we parse + /// by hand rather than through `jsonwebtoken`. + /// + /// Decodes [`ClientClaims`], not [`cts_common::claims::Claims`]: the server's + /// view requires every claim it enforces (`org_id` among them), and failing + /// an unverified client-side read of `workspace`/`iss` over a claim we never + /// look at would turn a server-side rejection into a total client outage. + fn decode_claims(&self) -> Result<ClientClaims, AuthError> { + crate::decode_jwt_payload(self.access_token.as_str()) + } + + /// Fuzz-only entry point: run the JWT claims decode (`Token::decode_claims`) + /// over an arbitrary string, discarding the claims and keeping only whether it + /// succeeded. Gated on the `fuzz` feature so it never appears in normal builds. + /// Reading claims from a token we already hold must never panic on a malformed + /// token — only return `Err`. See `packages/stack-auth/fuzz`. + /// + /// `#[doc(hidden)]`: the `doc:stack-auth` task builds with `--all-features`, + /// which enables `fuzz` — this keeps the shim out of the generated public docs. + #[cfg(feature = "fuzz")] + #[doc(hidden)] + pub fn fuzz_decode_claims(token: &str) -> Result<(), AuthError> { + Token { + access_token: SecretToken::new(token), + token_type: String::new(), + expires_at: 0, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + .decode_claims() + .map(|_| ()) + } + + /// Exchange a refresh token for a new [`Token`] via the `/oauth/token` + /// endpoint. + /// + /// This is a static constructor — it takes a bare [`SecretToken`] (the + /// refresh token) rather than operating on an existing `Token`. This + /// allows callers to manage the refresh token lifecycle independently + /// (e.g. taking it out of a cached token for cascade prevention and + /// restoring it on failure). + /// + /// # Errors + /// + /// - [`AuthError::InvalidGrant`] — the refresh token was revoked or expired. + /// - [`AuthError::InvalidClient`] — the client ID is not recognized. + /// - [`AuthError::Request`] — a network error occurred. + #[cfg(feature = "http")] + pub async fn refresh( + refresh_token: &SecretToken, + base_url: &Url, + client_id: &str, + device_instance_id: Option<&str>, + ) -> Result<Token, AuthError> { + Self::refresh_with( + &transport::default_transport(), + refresh_token, + base_url, + client_id, + device_instance_id, + ) + .await + } + + /// [`refresh`](Self::refresh) over a given transport: the form every + /// build has, and the one the device-session refresher calls. + pub(crate) async fn refresh_with( + transport: &SharedTransport, + refresh_token: &SecretToken, + base_url: &Url, + client_id: &str, + device_instance_id: Option<&str>, + ) -> Result<Token, AuthError> { + let token_url = base_url.join("oauth/token")?; + + tracing::debug!(url = %token_url, "refreshing token"); + + let resp = transport::post_form( + transport, + token_url, + &RefreshRequest { + grant_type: "refresh_token", + client_id, + refresh_token: refresh_token.as_str(), + device_instance_id, + }, + ) + .await?; + + if !resp.is_success() { + let status = resp.status(); + + // Read the body once as text and offer it to the shared classifier + // before parsing. Two reasons this order matters: `resp.json()` + // would turn a bodyless or non-JSON 402 into a decode error rather + // than the usage limit it is, and routing every issuance path + // through one classifier is what stops `/oauth/token` — the path + // `DeviceSessionRefresher` delegates to — from disagreeing with + // `/api/authorize` about what the same response means. + let body = resp.text(); + tracing::debug!(%status, %body, "token refresh failed"); + + if let Some(err) = crate::error::classify_issuance_failure(status, &body) { + return Err(err); + } + + let err: RefreshErrorResponse = serde_json::from_str(&body).map_err(|e| { + AuthError::Server(crate::error::ServerError(format!( + "{status}: unparseable error body: {e}" + ))) + })?; + + return Err(match err.error.as_str() { + "invalid_grant" => AuthError::InvalidGrant(crate::error::InvalidGrant), + "invalid_client" => AuthError::InvalidClient(crate::error::InvalidClient), + "access_denied" => AuthError::AccessDenied(crate::error::AccessDenied), + _ => AuthError::Server(crate::error::ServerError(err.error_description)), + }); + } + + let token_resp: RefreshResponse = resp.json()?; + + Ok(Token { + access_token: token_resp.access_token, + token_type: token_resp.token_type, + expires_at: now_unix_secs() + token_resp.expires_in, + refresh_token: token_resp.refresh_token, + region: None, + client_id: None, + // TODO(CIP-2793): The server should include device_instance_id in the + // refresh response. Until then, callers (e.g. DeviceSessionRefresher) must + // re-attach it manually after refresh. + device_instance_id: None, + }) + } +} + +#[derive(serde::Serialize)] +struct RefreshRequest<'a> { + grant_type: &'a str, + client_id: &'a str, + refresh_token: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + device_instance_id: Option<&'a str>, +} + +#[derive(serde::Deserialize)] +struct RefreshResponse { + access_token: SecretToken, + token_type: String, + expires_in: u64, + #[serde(default)] + refresh_token: Option<SecretToken>, +} + +/// The RFC 6749 error body, for failures the shared classifier declines. +/// +/// `cs_code` is deliberately absent: `classify_issuance_failure` inspects it +/// on the raw body before this type is ever constructed, so duplicating the +/// field here would create a second place for the two to disagree. +#[derive(serde::Deserialize)] +struct RefreshErrorResponse { + error: String, + #[serde(default)] + error_description: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{claims_with_workspace, jwt_token, raw_token}; + use crate::AuthError; + + fn make_token(expires_in: u64, refresh: bool) -> Token { + Token { + access_token: SecretToken::new("test-access-token"), + token_type: "Bearer".to_string(), + expires_at: now_unix_secs() + expires_in, + refresh_token: if refresh { + Some(SecretToken::new("test-refresh-token")) + } else { + None + }, + region: None, + client_id: None, + device_instance_id: None, + } + } + + #[test] + fn test_secret_token_debug_does_not_leak() { + let token = SecretToken("super_secret_value".to_string()); + let debug = format!("{:?}", token); + assert!( + !debug.contains("super_secret_value"), + "SecretToken Debug should not contain the secret, got: {debug}" + ); + } + + // ---- is_expired_at / is_usable_at boundary tests ---- + + /// A token with an explicit absolute `expires_at`, for driving the `*_at` + /// predicates against precise boundary values (unlike `make_token`, which is + /// relative to the wall clock). + fn token_expiring_at(expires_at: u64) -> Token { + Token { + access_token: SecretToken::new("t"), + token_type: "Bearer".to_string(), + expires_at, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + } + + #[test] + fn is_usable_at_boundary() { + let t = token_expiring_at(1000); + assert!(t.is_usable_at(999), "before expiry → usable"); + assert!(!t.is_usable_at(1000), "exactly at expiry → not usable"); + assert!(!t.is_usable_at(1001), "past expiry → not usable"); + } + + #[test] + fn is_expired_at_leeway_window() { + // EXPIRY_LEEWAY_SECS == 90: `is_expired_at` flips to true 90s ahead of + // the real expiry timestamp so refresh is triggered preemptively. + let t = token_expiring_at(1000); + assert!( + !t.is_expired_at(909), + "just outside the 90s leeway → not expired" + ); + assert!(t.is_expired_at(910), "exactly at the leeway edge → expired"); + // Inside the leeway window the token reads as "expired" (so a refresh is + // triggered) yet is still usable — this is the expired-but-usable state + // that drives AutoRefresh's non-blocking refresh path. + assert!( + t.is_expired_at(950) && t.is_usable_at(950), + "inside the leeway: expired but still usable" + ); + } + + /// The public, wall-clock predicates are what a caller holding a `Token` + /// actually consults, so each is pinned on both sides of its edge — an + /// hour either way of now, far outside the 90s leeway and any clock skew + /// within one test. + #[test] + fn wall_clock_predicates_refuse_an_expired_token() { + let expired = token_expiring_at(now_unix_secs() - 3600); + assert!(expired.is_expired(), "an hour past expiry → expired"); + assert!(!expired.is_usable(), "an hour past expiry → not usable"); + + let fresh = make_token(3600, false); + assert!(!fresh.is_expired(), "an hour before expiry → not expired"); + assert!(fresh.is_usable(), "an hour before expiry → usable"); + } + + #[test] + fn is_expired_at_saturates_near_u64_max() { + // `is_expired_at` computes `now + EXPIRY_LEEWAY_SECS`; a plain add would + // overflow and panic in debug builds. `test_support::raw_token` mints + // tokens with `expires_at == u64::MAX`, so the saturating add must hold. + let t = token_expiring_at(u64::MAX); + assert!( + t.is_expired_at(u64::MAX), + "saturating_add must not overflow at the u64 ceiling" + ); + } + + // ---- refresh() tests ---- + // + // Grouped under one feature gate rather than one per item: every helper + // and test below drives `Token::refresh` against a mock server, and both + // only exist with `http`. `make_token` and + // `test_refresh_debug_does_not_leak_tokens` deliberately stay outside — + // `Debug` must not leak secrets in any build. + #[cfg(feature = "http")] + mod refresh_tests { + use super::*; + use mocktail::prelude::*; + + fn refresh_response_json() -> serde_json::Value { + serde_json::json!({ + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh-token" + }) + } + + fn error_json(error: &str) -> serde_json::Value { + serde_json::json!({ + "error": error, + "error_description": format!("{error} occurred") + }) + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("token-refresh-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + #[tokio::test] + async fn test_refresh_success() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json()); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let refresh_token = SecretToken::new("test-refresh-token"); + let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None) + .await + .unwrap(); + + assert_eq!(refreshed.access_token().as_str(), "new-access-token"); + assert_eq!(refreshed.token_type(), "Bearer"); + assert_eq!( + refreshed.refresh_token().unwrap().as_str(), + "new-refresh-token" + ); + assert!(!refreshed.is_expired()); + assert!((3598..=3600).contains(&refreshed.expires_in())); + } + + #[tokio::test] + async fn test_refresh_invalid_grant() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let refresh_token = SecretToken::new("test-refresh-token"); + let err = Token::refresh(&refresh_token, &base_url, "cli", None) + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::InvalidGrant(_))); + } + + #[tokio::test] + async fn test_refresh_invalid_client() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("invalid_client")); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let refresh_token = SecretToken::new("test-refresh-token"); + let err = Token::refresh(&refresh_token, &base_url, "cli", None) + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::InvalidClient(_))); + } + + #[tokio::test] + async fn test_refresh_access_denied() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("access_denied")); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let refresh_token = SecretToken::new("test-refresh-token"); + let err = Token::refresh(&refresh_token, &base_url, "cli", None) + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::AccessDenied(_))); + } + + // ---- Usage-limit classification on the refresh path ---- + // + // `/oauth/token` is the path `DeviceSessionRefresher` delegates to, so + // these cases cover CLI login and dashboard refresh as well. They must + // agree with `classify_issuance_failure`, which the other two issuance + // paths use — the whole point of a shared classifier is that the same + // server response cannot mean different things depending on which + // refresher the caller happened to use. + + async fn refresh_against( + status: reqwest::StatusCode, + body: serde_json::Value, + ) -> AuthError { + let mut mocks = MockSet::new(); + mocks.mock(move |when, then| { + when.post().path("/oauth/token"); + then.status(status).json(body.clone()); + }); + let server = start_server(mocks).await; + let refresh_token = SecretToken::new("test-refresh-token"); + Token::refresh(&refresh_token, &server.url(""), "cli", None) + .await + .expect_err("a non-2xx refresh must fail") + } + + #[tokio::test] + async fn refresh_402_with_cs_code_is_usage_limit() { + let err = refresh_against( + reqwest::StatusCode::PAYMENT_REQUIRED, + serde_json::json!({ + "error": "access_denied", + "error_description": "Workspace has exceeded its usage limit", + "cs_code": "USAGE_LIMIT_EXCEEDED", + }), + ) + .await; + + assert_eq!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "cs_code must win over the registered access_denied code, or a usage \ + limit reads as a permissions failure the user cannot act on", + ); + assert!( + err.to_string().contains("exceeded its usage limit"), + "the server's description should survive verbatim, got {err}", + ); + } + + #[tokio::test] + async fn refresh_402_access_denied_without_cs_code_is_usage_limit() { + let err = refresh_against( + reqwest::StatusCode::PAYMENT_REQUIRED, + serde_json::json!({"error": "access_denied"}), + ) + .await; + + assert_eq!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "a CTS deployment predating cs_code still means usage limit at 402", + ); + } + + /// Guards arm ORDER: `access_denied` only means "usage limit" at 402. + #[tokio::test] + async fn refresh_403_access_denied_is_still_access_denied() { + let err = refresh_against( + reqwest::StatusCode::FORBIDDEN, + serde_json::json!({"error": "access_denied"}), + ) + .await; + + assert!( + matches!(err, AuthError::AccessDenied(_)), + "a non-402 access_denied is a real authorization refusal, got {err:?}", + ); + } + + /// Regression: this path used to parse the body as JSON *before* looking at + /// the status, so a bodyless 402 surfaced as a reqwest decode error while + /// the other two issuance paths classified it as a usage limit. Same server + /// response, two different client errors. + #[tokio::test] + async fn refresh_402_with_empty_body_is_usage_limit() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.status(reqwest::StatusCode::PAYMENT_REQUIRED); + }); + let server = start_server(mocks).await; + let refresh_token = SecretToken::new("test-refresh-token"); + + let err = Token::refresh(&refresh_token, &server.url(""), "cli", None) + .await + .expect_err("a 402 must fail"); + + assert_eq!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "must agree with classify_issuance_failure's bare-402 handling, got {err:?}", + ); + } + + /// A 402 whose `cs_code` we cannot read must not claim a usage limit — + /// mirrors `unreadable_cs_code_declines_to_classify` on the shared path. + #[tokio::test] + async fn refresh_402_with_unknown_cs_code_does_not_claim_usage_limit() { + let err = refresh_against( + reqwest::StatusCode::PAYMENT_REQUIRED, + serde_json::json!({"error": "access_denied", "cs_code": "SOMETHING_ELSE"}), + ) + .await; + + assert_ne!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "an unrecognised cs_code must not inherit the usage-limit classification", + ); + } + + #[tokio::test] + async fn test_refresh_unknown_error() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("something_unexpected")); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let refresh_token = SecretToken::new("test-refresh-token"); + let err = Token::refresh(&refresh_token, &base_url, "cli", None) + .await + .unwrap_err(); + + assert!( + matches!(&err, AuthError::Server(crate::error::ServerError(desc)) if desc == "something_unexpected occurred") + ); + } + + #[tokio::test] + async fn test_refresh_response_without_new_refresh_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(serde_json::json!({ + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600 + })); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let refresh_token = SecretToken::new("test-refresh-token"); + let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None) + .await + .unwrap(); + + assert_eq!(refreshed.access_token().as_str(), "new-access-token"); + assert!(refreshed.refresh_token().is_none()); + } + } + + // Deliberately not http-gated: `Debug` must not leak the secrets in any + // build, including the no-default-features shape the WASI guest ships. + #[tokio::test] + async fn test_refresh_debug_does_not_leak_tokens() { + let token = make_token(3600, true); + let debug = format!("{:?}", token); + assert!( + !debug.contains("test-access-token"), + "Debug output should not contain access token, got: {debug}" + ); + assert!( + !debug.contains("test-refresh-token"), + "Debug output should not contain refresh token, got: {debug}" + ); + } + + // ---- decode_claims / workspace_id / issuer tests ---- + + fn valid_claims_json() -> serde_json::Value { + claims_with_workspace("7366ITCXSAPCH5TN") + } + + #[test] + fn test_workspace_id_extracts_from_jwt() { + let token = jwt_token(valid_claims_json()); + let ws = token.workspace_id().expect("should extract workspace ID"); + assert_eq!(ws.to_string(), "7366ITCXSAPCH5TN"); + } + + #[test] + fn test_issuer_extracts_url_from_jwt() { + let token = jwt_token(valid_claims_json()); + let issuer = token.issuer().expect("should extract issuer"); + assert_eq!(issuer.as_str(), "https://cts.example.com/"); + } + + #[test] + fn test_workspace_id_fails_on_invalid_jwt() { + let token = raw_token("not-a-jwt"); + let err = token.workspace_id().unwrap_err(); + assert!(matches!(err, AuthError::InvalidToken(_))); + } + + #[test] + fn test_issuer_fails_on_missing_claims() { + let token = jwt_token(serde_json::json!({"sub": "user-123"})); + let err = token.issuer().unwrap_err(); + assert!(matches!(err, AuthError::InvalidToken(_))); + } + + /// The fuzz shim is the claim decoder, not a stub: the harness only + /// checks it never panics, so this pins that it still reports the + /// decoder's verdict both ways. + #[cfg(feature = "fuzz")] + #[test] + fn fuzz_decode_claims_reports_the_decoders_verdict() { + let valid = jwt_token(valid_claims_json()); + assert!(Token::fuzz_decode_claims(valid.access_token().as_str()).is_ok()); + assert!(matches!( + Token::fuzz_decode_claims("not.a.jwt"), + Err(AuthError::InvalidToken(_)) + )); + } + + #[test] + fn test_workspace_crn_derives_from_region_and_workspace() { + let mut token = jwt_token(valid_claims_json()); + // Assign the field directly: `set_region` is http-only (the device-code + // flow), but `workspace_crn` itself must stay covered without `http`. + token.region = Some("ap-southeast-2.aws".into()); + let crn = token.workspace_crn().expect("should derive workspace CRN"); + assert_eq!(crn.to_string(), "crn:ap-southeast-2.aws:7366ITCXSAPCH5TN"); + } + + #[test] + fn test_workspace_crn_fails_without_region() { + let token = jwt_token(valid_claims_json()); + let err = token.workspace_crn().unwrap_err(); + assert!(matches!(err, AuthError::NotAuthenticated(_))); + } + + #[test] + fn test_workspace_crn_fails_with_invalid_region() { + let mut token = jwt_token(valid_claims_json()); + token.region = Some("invalid-region".into()); + let err = token.workspace_crn().unwrap_err(); + assert!(matches!(err, AuthError::Server(_))); + } + + /// `org_id` is required *server-side*, where the signature is verified + /// first. This decode path verifies nothing and reads only `workspace` and + /// `iss`, so a token minted before `org_id` existed — or by a CTS rolled + /// back past the commit that added it — must still resolve both. + #[test] + fn workspace_id_and_issuer_resolve_without_org_id() { + let mut claims = valid_claims_json(); + claims + .as_object_mut() + .expect("claims fixture is a JSON object") + .remove("org_id"); + let token = jwt_token(claims); + + assert_eq!( + token + .workspace_id() + .expect("workspace must decode without org_id") + .to_string(), + "7366ITCXSAPCH5TN" + ); + assert_eq!( + token + .issuer() + .expect("iss must decode without org_id") + .as_str(), + "https://cts.example.com/" + ); + } +} diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs new file mode 100644 index 000000000..09fb6895e --- /dev/null +++ b/packages/stack-auth/src/token_store.rs @@ -0,0 +1,424 @@ +//! Pluggable persistence for service tokens. +//! +//! [`AutoRefresh`](crate::auto_refresh::AutoRefresh) consults a [`TokenStore`] +//! on cold start (no in-memory token) and writes back after every successful +//! refresh or initial auth. This lets strategies share a service-token cache +//! across short-lived processes — HTTP-only cookies in Edge Functions, KV +//! stores in Cloudflare Workers, Redis in multi-instance Node services, or a +//! shared cache across the CipherStash Proxy's worker pool. +//! +//! Wire a store onto a strategy via the builder: +//! +// The worked example builds an `AccessKeyStrategy`, which only exists with the +// `http` feature; without it a host brings its own strategy. +#![cfg_attr( + feature = "http", + doc = r#"```no_run +use std::sync::Arc; +use stack_auth::{AccessKey, AccessKeyStrategy, InMemoryTokenStore}; +use cts_common::Crn; + +let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); +let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); +let store = Arc::new(InMemoryTokenStore::new()); +let strategy = AccessKeyStrategy::builder(crn, key) + .with_token_store(store) + .build() + .unwrap(); +```"# +)] +#![cfg_attr( + not(feature = "http"), + doc = "(The bundled strategies and their builders live behind the `http` feature.)" +)] +//! +//! For cookie-style storage where the load/save logic lives in the calling +//! request handler, use [`TokenStoreFn::new`] with two async closures +//! that deal in JSON strings: +//! +//! ```no_run +//! use std::sync::Arc; +//! use stack_auth::TokenStoreFn; +//! +//! let store = Arc::new(TokenStoreFn::new( +//! || async { /* read cookie */ None::<String> }, +//! |_json: String| async move { /* write Set-Cookie header */ }, +//! )); +//! ``` +//! +//! See also: [`AuthStrategyFn`](crate::AuthStrategyFn) — the closure-shaped +//! impl of the *acquisition* layer ([`AuthStrategy`](crate::AuthStrategy)). +//! `TokenStoreFn` plugs into an existing strategy as a persistence backend; +//! `AuthStrategyFn` replaces the whole acquisition pipeline (used by FFI +//! consumers like `protect-ffi` that source tokens from JS). + +use std::future::Future; +use std::sync::Arc; + +use tokio::sync::Mutex; +use zeroize::Zeroizing; + +use crate::Token; + +/// Pluggable persistent cache for service tokens. +/// +/// Implementations are consulted by `AutoRefresh` whenever it has no +/// in-memory token (cold start), and written to after every successful +/// refresh or initial authentication. Implementations should treat both +/// methods as best-effort — `load` returns [`None`] for "no token, or load +/// failed", `save` is fire-and-forget. The `AutoRefresh` state machine +/// always validates freshness via [`Token::is_usable`] / [`Token::is_expired`] +/// before returning a loaded token, so implementations don't need to. +/// +/// On native targets the trait carries `Send + Sync` bounds so the store can +/// be shared across `tokio::spawn` background work. On wasm32 the bounds are +/// dropped — edge runtimes are single-threaded. +#[cfg(not(target_arch = "wasm32"))] +pub trait TokenStore: Send + Sync { + /// Load the most recently saved token, or `None` if none has been stored + /// (or the load failed). Errors are swallowed — the calling state machine + /// falls back to fresh authentication when this returns `None`. + fn load(&self) -> impl Future<Output = Option<Token>> + Send; + + /// Persist a token after a successful refresh or initial authentication. + /// Best-effort — implementations should log on failure rather than + /// returning an error. + fn save(&self, token: &Token) -> impl Future<Output = ()> + Send; +} + +#[cfg(target_arch = "wasm32")] +pub trait TokenStore { + fn load(&self) -> impl Future<Output = Option<Token>>; + fn save(&self, token: &Token) -> impl Future<Output = ()>; +} + +/// Forward [`TokenStore`] through `Arc` so one store can back many strategy +/// instances (Edge Function pool, CipherStash Proxy worker pool, etc). +#[cfg(not(target_arch = "wasm32"))] +impl<T: TokenStore + ?Sized> TokenStore for Arc<T> { + fn load(&self) -> impl Future<Output = Option<Token>> + Send { + (**self).load() + } + + fn save(&self, token: &Token) -> impl Future<Output = ()> + Send { + (**self).save(token) + } +} + +#[cfg(target_arch = "wasm32")] +impl<T: TokenStore + ?Sized> TokenStore for Arc<T> { + fn load(&self) -> impl Future<Output = Option<Token>> { + (**self).load() + } + + fn save(&self, token: &Token) -> impl Future<Output = ()> { + (**self).save(token) + } +} + +/// Zero-sized default for `AutoRefresh<R, S = NoStore>` — `load` returns +/// `None`, `save` is a no-op. Carries no per-instance cost. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoStore; + +impl TokenStore for NoStore { + async fn load(&self) -> Option<Token> { + None + } + + async fn save(&self, _token: &Token) {} +} + +/// In-process token store. Useful for tests and as a shared cache across +/// multiple strategy instances in the same process (e.g. a worker pool). +/// +/// Internally stores the JSON-serialised form of the token wrapped in +/// [`Zeroizing`] so the buffer is wiped on overwrite and on store drop. The +/// [`SecretToken`](crate::SecretToken) wrapped inside [`Token`] is +/// [`ZeroizeOnDrop`](zeroize::ZeroizeOnDrop), so we deliberately don't clone +/// the in-memory `Token` value — round-tripping through serde gives us a +/// fresh `SecretToken` on each `load` without violating that invariant. +pub struct InMemoryTokenStore { + state: Mutex<Option<Zeroizing<String>>>, +} + +impl InMemoryTokenStore { + /// Create a new, empty in-memory token store. + pub fn new() -> Self { + Self { + state: Mutex::new(None), + } + } +} + +impl Default for InMemoryTokenStore { + fn default() -> Self { + Self::new() + } +} + +impl TokenStore for InMemoryTokenStore { + async fn load(&self) -> Option<Token> { + let guard = self.state.lock().await; + let json = guard.as_ref()?; + serde_json::from_str(json).ok() + } + + async fn save(&self, token: &Token) { + let Ok(json) = serde_json::to_string(token) else { + tracing::warn!("InMemoryTokenStore: failed to serialise token"); + return; + }; + let mut guard = self.state.lock().await; + *guard = Some(Zeroizing::new(json)); + } +} + +/// [`TokenStore`] backed by user-supplied `load` and `save` async closures. +/// +/// This is the *persistence layer* primitive — it plugs into an existing +/// strategy so that strategy can share its service-token cache across +/// processes. +// The named example only exists with `http`. +#[cfg_attr( + feature = "http", + doc = "(For example [`AccessKeyStrategy`](crate::AccessKeyStrategy).)\n" +)] +/// For wiring +/// in a complete *acquisition pipeline* (e.g. a JS-defined strategy across +/// an FFI boundary), use [`AuthStrategyFn`](crate::AuthStrategyFn) instead. +/// +/// Closures deal in JSON strings — the on-the-wire form of [`Token`] — not +/// the `Token` type itself. This keeps the caller's signatures free of +/// `stack-auth` internals and matches the natural shape of common storage +/// substrates: a cookie value, a KV blob, a Redis string. +/// +/// The closure return types are generic so async blocks / `async ||` +/// closures / `async fn` adapters all compose without boxing. +/// +/// **Secret-material handling.** The JSON string passed to the `save` +/// closure contains the bearer token verbatim (via +/// [`SecretToken`](crate::SecretToken)'s `#[serde(transparent)]` impl). Once +/// the value crosses into the user's closure, `stack-auth` has no control +/// over zeroize semantics — implementations should treat the input as +/// secret material and clear any local copies promptly. `load` wraps the +/// returned string in [`Zeroizing`] internally, so the buffer is wiped +/// after deserialisation. End-to-end protection at rest (e.g. encrypting +/// the value before it ever leaves the worker) is tracked as a future +/// `EncryptedTokenStore` decorator. +pub struct TokenStoreFn<L, S> { + load: L, + save: S, +} + +impl<L, S> TokenStoreFn<L, S> { + /// Build a token store from a `load` closure (returns the stored JSON, or + /// `None` if nothing is cached) and a `save` closure (persists the JSON). + /// + /// See the module-level documentation for an example. + pub fn new(load: L, save: S) -> Self { + Self { load, save } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl<L, LF, S, SF> TokenStore for TokenStoreFn<L, S> +where + L: Fn() -> LF + Send + Sync, + LF: Future<Output = Option<String>> + Send, + S: Fn(String) -> SF + Send + Sync, + SF: Future<Output = ()> + Send, +{ + async fn load(&self) -> Option<Token> { + let json = Zeroizing::new((self.load)().await?); + // Don't log the underlying serde_json error — its `Display` impl can + // include byte positions of unexpected tokens, leaking partial token + // content if the input was mid-parse when it failed. + serde_json::from_str(&json).ok() + } + + async fn save(&self, token: &Token) { + let Ok(json) = serde_json::to_string(token) else { + tracing::warn!("TokenStoreFn: failed to serialise token"); + return; + }; + (self.save)(json).await; + } +} + +#[cfg(target_arch = "wasm32")] +impl<L, LF, S, SF> TokenStore for TokenStoreFn<L, S> +where + L: Fn() -> LF, + LF: Future<Output = Option<String>>, + S: Fn(String) -> SF, + SF: Future<Output = ()>, +{ + async fn load(&self) -> Option<Token> { + let json = Zeroizing::new((self.load)().await?); + // Don't log the underlying serde_json error — its `Display` impl can + // include byte positions of unexpected tokens, leaking partial token + // content if the input was mid-parse when it failed. + serde_json::from_str(&json).ok() + } + + async fn save(&self, token: &Token) { + let Ok(json) = serde_json::to_string(token) else { + tracing::warn!("TokenStoreFn: failed to serialise token"); + return; + }; + (self.save)(json).await; + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use crate::SecretToken; + + use super::*; + + fn dummy_token(expires_at: u64) -> Token { + Token { + access_token: SecretToken::new("dummy-access".to_string()), + refresh_token: None, + token_type: "Bearer".to_string(), + expires_at, + region: None, + client_id: None, + device_instance_id: None, + } + } + + #[tokio::test] + async fn in_memory_load_returns_none_when_empty() { + let store = InMemoryTokenStore::new(); + assert!( + store.load().await.is_none(), + "freshly constructed store should hold no token" + ); + } + + #[tokio::test] + async fn in_memory_round_trip_preserves_expires_at() { + let store = InMemoryTokenStore::new(); + store.save(&dummy_token(4_000_000_000)).await; + let loaded = store + .load() + .await + .expect("load should return the saved token"); + assert_eq!( + loaded.expires_at(), + 4_000_000_000, + "round-trip should preserve expires_at" + ); + assert_eq!( + loaded.token_type(), + "Bearer", + "round-trip should preserve token_type" + ); + } + + #[tokio::test] + async fn in_memory_save_overwrites_previous() { + let store = InMemoryTokenStore::new(); + store.save(&dummy_token(1_000_000_000)).await; + store.save(&dummy_token(2_000_000_000)).await; + let loaded = store.load().await.expect("store should hold a token"); + assert_eq!( + loaded.expires_at(), + 2_000_000_000, + "second save should replace the first" + ); + } + + #[tokio::test] + async fn callback_store_invokes_load_closure_each_call() { + let calls = Arc::new(AtomicUsize::new(0)); + let calls_clone = Arc::clone(&calls); + let store = TokenStoreFn::new( + move || { + let calls = Arc::clone(&calls_clone); + async move { + let n = calls.fetch_add(1, Ordering::SeqCst); + if n == 0 { + None + } else { + Some(serde_json::to_string(&dummy_token(4_000_000_000)).unwrap()) + } + } + }, + |_json: String| async move {}, + ); + + assert!( + store.load().await.is_none(), + "first load returns None because the closure does" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "first call should have invoked the load closure exactly once" + ); + + let loaded = store + .load() + .await + .expect("second load should yield a token"); + assert_eq!( + loaded.expires_at(), + 4_000_000_000, + "deserialised token should preserve the JSON payload's expires_at" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "second call should have invoked the load closure a second time" + ); + } + + #[tokio::test] + async fn callback_store_forwards_serialised_token_to_save_closure() { + let captured = Arc::new(Mutex::new(None::<String>)); + let captured_clone = Arc::clone(&captured); + let store = TokenStoreFn::new( + || async { None }, + move |json: String| { + let captured = Arc::clone(&captured_clone); + async move { + *captured.lock().await = Some(json); + } + }, + ); + + store.save(&dummy_token(4_000_000_000)).await; + let json = captured + .lock() + .await + .clone() + .expect("save closure should have captured the JSON"); + assert!( + json.contains("\"expires_at\":4000000000"), + "captured JSON should encode expires_at; got: {json}" + ); + assert!( + json.contains("\"token_type\":\"Bearer\""), + "captured JSON should encode token_type; got: {json}" + ); + } + + #[tokio::test] + async fn callback_store_ignores_invalid_json_on_load() { + let store = TokenStoreFn::new( + || async { Some("not valid json".to_string()) }, + |_json: String| async move {}, + ); + assert!( + store.load().await.is_none(), + "invalid JSON from the load closure should be treated as cache miss" + ); + } +} diff --git a/packages/stack-auth/src/transport.rs b/packages/stack-auth/src/transport.rs new file mode 100644 index 000000000..33c8b1364 --- /dev/null +++ b/packages/stack-auth/src/transport.rs @@ -0,0 +1,954 @@ +//! The HTTP seam: how a strategy's requests reach the network. +//! +//! Every exchange this crate makes has one shape — post a body to a URL, +//! read the status and body back — and that is the whole of +//! [`HttpTransport`]. Its request and response are the ones the Go +//! binding's guest already carries across its `transport_send` host +//! import: method, URL, headers and body in; status, headers and body out; +//! all bytes, no streaming. So a host with its own HTTP client implements +//! the trait, and the strategies run unchanged over it — inside a wasm +//! module with no TLS stack of its own, or anywhere else `reqwest` is the +//! wrong choice. +//! +//! With the `http` feature, [`ReqwestTransport`] is the implementation +//! every builder uses unless told otherwise, so native callers see no +//! difference. Without it, a builder must be handed a transport. +//! +//! Bodies and header values are wiped on drop on both halves: a request +//! carries an access key or a refresh token and a bearer credential, and a +//! response carries the token that was minted. Neither type prints any of +//! that: `Debug` reports the method, URL, header names and body length. +//! The wipe covers this crate's buffers; what an HTTP client copies into +//! its own is that client's, and [`ReqwestTransport`] says what it does. + +use std::fmt; +use std::future::Future; +use std::sync::{Arc, OnceLock}; + +use url::Url; +use zeroize::Zeroizing; + +use crate::error::RequestError; +use crate::AuthError; + +/// One HTTP request, as a transport receives it. +/// +/// The shape is the guest host import's, deliberately: a transport that +/// can carry this can carry every request the crate makes, and nothing the +/// crate makes needs more. +pub struct HttpRequest { + method: &'static str, + url: Url, + headers: Zeroizing<Vec<(String, String)>>, + body: Zeroizing<Vec<u8>>, +} + +impl HttpRequest { + /// A request. `method` is upper-case (`"POST"`); header names are + /// lower-case. This is what the crate builds internally; a transport + /// implementation outside the crate needs it only to test itself. + pub fn new( + method: &'static str, + url: Url, + headers: Vec<(String, String)>, + body: Vec<u8>, + ) -> Self { + Self { + method, + url, + headers: Zeroizing::new(headers), + body: Zeroizing::new(body), + } + } + + /// The HTTP method, upper-case (`"POST"`). + pub fn method(&self) -> &str { + self.method + } + + /// The absolute URL to send to. + pub fn url(&self) -> &Url { + &self.url + } + + /// The request headers, in order. Names are lower-case. Values are + /// wiped when the request is dropped: one of them is the bearer + /// credential. + pub fn headers(&self) -> &[(String, String)] { + &self.headers + } + + /// The request body. Wiped when the request is dropped. + pub fn body(&self) -> &[u8] { + &self.body + } +} + +/// Names only: a request or response carries credentials in its body and +/// its header values, and `{:?}` in a log line is how those leak. +impl fmt::Debug for HttpRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HttpRequest") + .field("method", &self.method) + .field("url", &self.url.as_str()) + .field("headers", &HeaderNames(&self.headers)) + .field("body_len", &self.body.len()) + .finish() + } +} + +/// One HTTP response, as a transport returns it. +pub struct HttpResponse { + status: u16, + headers: Zeroizing<Vec<(String, String)>>, + body: Zeroizing<Vec<u8>>, +} + +impl HttpResponse { + /// A response with `status`, `headers` and `body`. The body and the + /// header values are wiped when the response is dropped. + pub fn new(status: u16, headers: Vec<(String, String)>, body: Vec<u8>) -> Self { + Self { + status, + headers: Zeroizing::new(headers), + body: Zeroizing::new(body), + } + } + + /// The HTTP status code. + pub fn status(&self) -> u16 { + self.status + } + + /// The response headers, in order. + pub fn headers(&self) -> &[(String, String)] { + &self.headers + } + + /// The response body. + pub fn body(&self) -> &[u8] { + &self.body + } + + pub(crate) fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } + + /// The body as text, for logging and for the error classifiers. + pub(crate) fn text(&self) -> String { + String::from_utf8_lossy(&self.body).into_owned() + } + + /// The body decoded as JSON. A body that does not decode is reported as + /// a request failure, as it was when the HTTP client did the decoding. + pub(crate) fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, RequestError> { + serde_json::from_slice(&self.body).map_err(|e| RequestError(Box::new(e))) + } +} + +impl fmt::Debug for HttpResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HttpResponse") + .field("status", &self.status) + .field("headers", &HeaderNames(&self.headers)) + .field("body_len", &self.body.len()) + .finish() + } +} + +/// The names of a header list, for the `Debug` impls above. +struct HeaderNames<'a>(&'a [(String, String)]); + +impl fmt::Debug for HeaderNames<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_list() + .entries(self.0.iter().map(|(name, _)| name)) + .finish() + } +} + +/// Carries one HTTP request and returns its response. +/// +/// On native targets the trait carries `Send + Sync` bounds so a strategy +/// holding a transport can be driven from `tokio::spawn` background work. +/// On wasm32 the bounds are dropped — a fetch-backed future is not `Send` +/// and edge runtimes are single-threaded anyway — matching every other +/// async trait in this crate. +/// +/// A failure to get any response at all (the host is unreachable, the +/// connection dropped) is a [`RequestError`]. A response with an error +/// status is not a failure of the transport: return it, and the strategy +/// classifies it. +#[cfg(not(target_arch = "wasm32"))] +pub trait HttpTransport: Send + Sync + 'static { + /// Send `request` and return the response. + fn send( + &self, + request: HttpRequest, + ) -> impl Future<Output = Result<HttpResponse, RequestError>> + Send; +} + +/// Wasm32 variant of [`HttpTransport`] — drops the `Send + Sync` bounds. +#[cfg(target_arch = "wasm32")] +pub trait HttpTransport: 'static { + /// Send `request` and return the response. + fn send( + &self, + request: HttpRequest, + ) -> impl Future<Output = Result<HttpResponse, RequestError>>; +} + +/// One transport can serve several strategies: hand each an `Arc` of it. +#[cfg(not(target_arch = "wasm32"))] +impl<T: HttpTransport> HttpTransport for Arc<T> { + fn send( + &self, + request: HttpRequest, + ) -> impl Future<Output = Result<HttpResponse, RequestError>> + Send { + (**self).send(request) + } +} + +/// One transport can serve several strategies: hand each an `Arc` of it. +#[cfg(target_arch = "wasm32")] +impl<T: HttpTransport> HttpTransport for Arc<T> { + fn send( + &self, + request: HttpRequest, + ) -> impl Future<Output = Result<HttpResponse, RequestError>> { + (**self).send(request) + } +} + +// --------------------------------------------------------------------------- +// The crate-internal, object-safe view. +// +// `HttpTransport` returns `impl Future`, which is the crate's convention and +// the easiest thing to implement — and not object-safe. The strategies want +// one concrete type for "whatever transport was configured" rather than a +// type parameter on every public strategy, so this adapter boxes the future +// once, at construction, and nothing else in the crate names the transport's +// concrete type again. +// --------------------------------------------------------------------------- + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) trait DynTransport: Send + Sync { + fn send_dyn<'a>( + &'a self, + request: HttpRequest, + ) -> std::pin::Pin<Box<dyn Future<Output = Result<HttpResponse, RequestError>> + Send + 'a>>; +} + +#[cfg(not(target_arch = "wasm32"))] +impl<T: HttpTransport> DynTransport for T { + fn send_dyn<'a>( + &'a self, + request: HttpRequest, + ) -> std::pin::Pin<Box<dyn Future<Output = Result<HttpResponse, RequestError>> + Send + 'a>> + { + Box::pin(self.send(request)) + } +} + +#[cfg(target_arch = "wasm32")] +pub(crate) trait DynTransport { + fn send_dyn<'a>( + &'a self, + request: HttpRequest, + ) -> std::pin::Pin<Box<dyn Future<Output = Result<HttpResponse, RequestError>> + 'a>>; +} + +#[cfg(target_arch = "wasm32")] +impl<T: HttpTransport> DynTransport for T { + fn send_dyn<'a>( + &'a self, + request: HttpRequest, + ) -> std::pin::Pin<Box<dyn Future<Output = Result<HttpResponse, RequestError>> + 'a>> { + Box::pin(self.send(request)) + } +} + +/// The transport a strategy holds: whichever implementation it was built +/// with, behind one type. +pub(crate) type SharedTransport = Arc<dyn DynTransport>; + +/// Box `transport` once, for the strategies to share. +pub(crate) fn share(transport: impl HttpTransport) -> SharedTransport { + Arc::new(transport) +} + +/// The transport a builder ends up with: the one it was given, else the +/// bundled `reqwest` client, else an error — a strategy cannot exist +/// without a way to send. +pub(crate) fn resolve(configured: Option<SharedTransport>) -> Result<SharedTransport, AuthError> { + match configured { + Some(transport) => Ok(transport), + #[cfg(feature = "http")] + None => Ok(default_transport()), + #[cfg(not(feature = "http"))] + None => Err(AuthError::Request(RequestError(Box::new(NoTransport)))), + } +} + +/// No transport was configured and the crate was built without `http`. +#[cfg(not(feature = "http"))] +#[derive(Debug)] +pub(crate) struct NoTransport; + +#[cfg(not(feature = "http"))] +impl std::fmt::Display for NoTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str( + "no HTTP transport: this build of stack-auth has no `http` feature, \ + so the strategy must be given one with `.transport(..)`", + ) + } +} + +#[cfg(not(feature = "http"))] +impl std::error::Error for NoTransport {} + +// --------------------------------------------------------------------------- +// The two request shapes the crate makes. +// --------------------------------------------------------------------------- + +/// The `user-agent` every request this crate builds carries: +/// `stack-auth/<version> (<os> <arch>)`. +/// +/// Not cosmetic. The edge in front of production CTS answers a request whose +/// `user-agent` is a runtime's generic default (Go's `Go-http-client/1.1`) +/// with a bare nginx 403 that never reaches CTS. Natively, reqwest sends no +/// `user-agent` of its own, which that edge happens to let through; a host +/// transport whose HTTP client fills one in when the request has none (the +/// Go binding's) was refused on every exchange. So the crate names itself +/// on every request rather than leaving the header to whatever client +/// carries it. A host transport may replace the value with one naming +/// itself (the Go guest sends `stack-auth/<version> (Go)`), but it is never +/// absent. +pub(crate) fn user_agent() -> &'static str { + static USER_AGENT: OnceLock<String> = OnceLock::new(); + USER_AGENT.get_or_init(|| { + format!( + "stack-auth/{} ({} {})", + crate::VERSION, + std::env::consts::OS, + std::env::consts::ARCH, + ) + }) +} + +/// `POST` a JSON body. +pub(crate) async fn post_json<B: serde::Serialize>( + transport: &SharedTransport, + url: Url, + body: &B, +) -> Result<HttpResponse, RequestError> { + let body = Zeroizing::new(serde_json::to_vec(body).map_err(|e| RequestError(Box::new(e)))?); + post(transport, url, "application/json", Vec::new(), body).await +} + +/// `POST` a form (`application/x-www-form-urlencoded`) body. +pub(crate) async fn post_form<B: serde::Serialize>( + transport: &SharedTransport, + url: Url, + body: &B, +) -> Result<HttpResponse, RequestError> { + let body = Zeroizing::new( + serde_urlencoded::to_string(body) + .map_err(|e| RequestError(Box::new(e)))? + .into_bytes(), + ); + post( + transport, + url, + "application/x-www-form-urlencoded", + Vec::new(), + body, + ) + .await +} + +/// `POST` `body` as `content_type`, with `extra` headers first and then +/// `content-type` and [`user_agent`]. A failure +/// here is the transport's (or the encoder's); the caller lifts it into +/// its own error type, which for a strategy is `AuthError::Request`. +pub(crate) async fn post( + transport: &SharedTransport, + url: Url, + content_type: &str, + mut extra: Vec<(String, String)>, + body: Zeroizing<Vec<u8>>, +) -> Result<HttpResponse, RequestError> { + extra.push(("content-type".to_string(), content_type.to_string())); + extra.push(("user-agent".to_string(), user_agent().to_string())); + let request = HttpRequest { + method: "POST", + url, + headers: Zeroizing::new(extra), + body, + }; + transport.send_dyn(request).await +} + +// --------------------------------------------------------------------------- +// The bundled implementation. +// --------------------------------------------------------------------------- + +/// [`HttpTransport`] over a [`reqwest::Client`]: what every strategy uses +/// unless a builder is given something else. +/// +/// [`Default`] builds the client with the crate's standard timeouts and +/// pool settings; [`ReqwestTransport::new`] takes a client configured by +/// the caller. +/// +/// What it does with the secrets it is handed: the request body is given +/// to reqwest as a buffer this crate still owns, so it is wiped when reqwest +/// is done with it rather than copied into an ordinary allocation; the +/// `authorization` header is marked sensitive, so reqwest's and hyper's +/// own `Debug` output redact it. What it cannot do: reach the buffers +/// reqwest and hyper allocate for themselves while sending and receiving. +/// Those are theirs, and this crate's wipe guarantee stops at its own. +#[cfg(feature = "http")] +#[derive(Debug, Clone)] +pub struct ReqwestTransport { + client: reqwest::Client, +} + +#[cfg(feature = "http")] +impl ReqwestTransport { + /// A transport over `client`. + pub fn new(client: reqwest::Client) -> Self { + Self { client } + } +} + +#[cfg(feature = "http")] +impl Default for ReqwestTransport { + fn default() -> Self { + Self::new(http_client()) + } +} + +#[cfg(feature = "http")] +impl HttpTransport for ReqwestTransport { + async fn send(&self, request: HttpRequest) -> Result<HttpResponse, RequestError> { + use reqwest::header::HeaderValue; + + let HttpRequest { + method, + url, + headers, + body, + } = request; + let method = reqwest::Method::from_bytes(method.as_bytes()) + .map_err(|e| RequestError(Box::new(e)))?; + let mut builder = self.client.request(method, url); + for (name, value) in headers.iter() { + let mut value = HeaderValue::from_str(value).map_err(|e| RequestError(Box::new(e)))?; + if name.eq_ignore_ascii_case("authorization") { + value.set_sensitive(true); + } + builder = builder.header(name.as_str(), value); + } + // `from_owner` lends reqwest the buffer instead of copying it: the + // `Zeroizing` is dropped, and wiped, when the body is. + let response = builder.body(bytes::Bytes::from_owner(body)).send().await?; + let status = response.status().as_u16(); + let headers = response + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + String::from_utf8_lossy(value.as_bytes()).into_owned(), + ) + }) + .collect(); + let body = response.bytes().await?.to_vec(); + Ok(HttpResponse::new(status, headers, body)) + } +} + +/// The bundled transport, boxed for the strategies. +#[cfg(feature = "http")] +pub(crate) fn default_transport() -> SharedTransport { + share(ReqwestTransport::default()) +} + +/// Create a [`reqwest::Client`] with standard timeouts. +/// +/// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)` +/// does not auto-advance time past the connect timeout before the mock server +/// can respond. On wasm32, reqwest's fetch backend doesn't expose +/// `connect_timeout`/`pool_*` — the host runtime owns those concerns. +#[cfg(all(feature = "http", any(test, feature = "test-utils")))] +fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +#[cfg(all( + feature = "http", + not(any(test, feature = "test-utils")), + not(target_arch = "wasm32") +))] +fn http_client() -> reqwest::Client { + use std::time::Duration; + + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .pool_idle_timeout(Duration::from_secs(5)) + .pool_max_idle_per_host(10) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +#[cfg(all( + feature = "http", + not(any(test, feature = "test-utils")), + target_arch = "wasm32" +))] +fn http_client() -> reqwest::Client { + // Wasm32 reqwest uses the host's `fetch`; timeouts and pooling are owned + // by the runtime, so `ClientBuilder` doesn't expose them here. + reqwest::Client::builder() + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + use crate::access_key_refresher::AccessKeyRefresher; + use crate::oidc_refresher::{OidcProviderFn, OidcRefresher}; + use crate::refresher::Refresher; + use crate::{SecretToken, Token}; + + /// What the stub saw: method, URL, headers, body. + type Seen = (String, String, Vec<(String, String)>, Vec<u8>); + + /// A transport that answers every request with one canned response and + /// remembers what it was asked, so a test can pin the wire shape without + /// an HTTP client in the build. + struct Stub { + response: Result<(u16, &'static str), &'static str>, + seen: Mutex<Vec<Seen>>, + } + + impl Stub { + fn replying(status: u16, body: &'static str) -> Self { + Self { + response: Ok((status, body)), + seen: Mutex::new(Vec::new()), + } + } + + fn failing(message: &'static str) -> Self { + Self { + response: Err(message), + seen: Mutex::new(Vec::new()), + } + } + } + + impl HttpTransport for Stub { + async fn send(&self, request: HttpRequest) -> Result<HttpResponse, RequestError> { + self.seen.lock().unwrap().push(( + request.method().to_string(), + request.url().to_string(), + request.headers().to_vec(), + request.body().to_vec(), + )); + match self.response { + Ok((status, body)) => Ok(HttpResponse::new(status, Vec::new(), body.into())), + Err(message) => Err(RequestError(Box::new(std::io::Error::other(message)))), + } + } + } + + fn base_url() -> Url { + "https://cts.example.com/".parse().unwrap() + } + + fn workspace_id() -> cts_common::WorkspaceId { + "ZVATKW3VHMFG27DY".parse().unwrap() + } + + fn seen(stub: &Arc<Stub>) -> Seen { + stub.seen.lock().unwrap().remove(0) + } + + /// The crate itself reads a response through `text()`/`json()`; the + /// public accessors are what a host transport's own tests (and the FFI + /// bindings) read, so they must hand back exactly what was built. + #[test] + fn a_response_reads_back_what_it_was_built_with() { + let headers = vec![("content-type".to_string(), "application/json".to_string())]; + let response = HttpResponse::new(201, headers.clone(), b"{\"ok\":true}".to_vec()); + + assert_eq!(response.status(), 201, "response should retain its status"); + assert_eq!( + response.headers(), + headers.as_slice(), + "response should retain its headers" + ); + assert_eq!( + response.body(), + b"{\"ok\":true}", + "response should retain its body" + ); + } + + #[test] + fn debug_output_names_headers_and_never_prints_a_secret() { + let request = HttpRequest::new( + "POST", + base_url(), + vec![ + ("authorization".into(), "Bearer SECRET-TOKEN".into()), + ("content-type".into(), "application/json".into()), + ], + br#"{"accessKey":"CSAK-SECRET"}"#.to_vec(), + ); + let shown = format!("{request:?}"); + assert!( + shown.contains("authorization") && shown.contains("content-type"), + "{shown}" + ); + assert!(shown.contains("body_len: 27"), "{shown}"); + assert!(!shown.contains("SECRET"), "{shown}"); + + let response = HttpResponse::new( + 200, + vec![("set-cookie".into(), "session=SECRET".into())], + br#"{"accessToken":"SECRET"}"#.to_vec(), + ); + let shown = format!("{response:?}"); + assert!( + shown.contains("status: 200") && shown.contains("set-cookie"), + "{shown}" + ); + assert!(!shown.contains("SECRET"), "{shown}"); + } + + #[tokio::test] + async fn a_shared_transport_serves_more_than_one_strategy() { + let stub = Arc::new(Stub::replying( + 200, + r#"{"accessToken":"svc","expiry":4102444800}"#, + )); + let one: SharedTransport = share(Arc::clone(&stub)); + let two: SharedTransport = share(stub.clone()); + for transport in [one, two] { + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + base_url(), + None, + transport, + ); + let _ = refresher.refresh(&()).await; + } + assert_eq!( + stub.seen.lock().unwrap().len(), + 2, + "both strategies reached the one transport" + ); + } + + /// The `user-agent` the crate's requests must carry, spelled out rather + /// than read back from [`user_agent`], so a change to it is a test + /// change. + fn expected_user_agent() -> String { + format!( + "stack-auth/{} ({} {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + ) + } + + /// The `user-agent` values in `headers`: there must be exactly one. + fn user_agents(headers: &[(String, String)]) -> Vec<&str> { + headers + .iter() + .filter(|(name, _)| name == "user-agent") + .map(|(_, value)| value.as_str()) + .collect() + } + + /// The edge in front of production CTS refuses a request whose + /// `user-agent` is a runtime's generic default with a bare 403, and a + /// host transport's HTTP client fills one in when the request carries + /// none. Both request shapes the crate makes name the crate themselves. + #[tokio::test] + async fn every_request_identifies_itself() { + let stub = Arc::new(Stub::replying(200, "{}")); + let transport: SharedTransport = stub.clone(); + + post_json(&transport, base_url(), &serde_json::json!({"a": 1})) + .await + .unwrap(); + post_form(&transport, base_url(), &[("a", "1")]) + .await + .unwrap(); + post( + &transport, + base_url(), + "application/json", + vec![("authorization".into(), "Bearer tok".into())], + Zeroizing::new(Vec::new()), + ) + .await + .unwrap(); + + let expected = expected_user_agent(); + for (shape, content_type) in [ + ("post_json", "application/json"), + ("post_form", "application/x-www-form-urlencoded"), + ("post with extra headers", "application/json"), + ] { + let (_, _, headers, _) = seen(&stub); + assert_eq!( + user_agents(&headers), + vec![expected.as_str()], + "{shape}: exactly one user-agent, naming the crate, version and platform" + ); + assert!( + headers.contains(&("content-type".to_string(), content_type.to_string())), + "{shape}: the content type travels with the user-agent: {headers:?}" + ); + } + } + + #[test] + fn the_user_agent_names_the_crate_version_and_platform() { + assert_eq!(user_agent(), expected_user_agent()); + assert_eq!(crate::VERSION, env!("CARGO_PKG_VERSION")); + } + + #[tokio::test] + async fn refresh_posts_a_form_and_reads_the_token() { + let stub = Arc::new(Stub::replying( + 200, + r#"{"access_token":"new","token_type":"Bearer","expires_in":3600,"refresh_token":"rotated"}"#, + )); + let transport: SharedTransport = stub.clone(); + + let token = Token::refresh_with( + &transport, + &SecretToken::new("rt"), + &base_url(), + "cli", + None, + ) + .await + .unwrap(); + + assert_eq!(token.access_token().as_str(), "new"); + assert_eq!(token.refresh_token().unwrap().as_str(), "rotated"); + let (method, url, headers, body) = seen(&stub); + assert_eq!(method, "POST"); + assert_eq!(url, "https://cts.example.com/oauth/token"); + assert!(headers.contains(&( + "content-type".to_string(), + "application/x-www-form-urlencoded".to_string() + ))); + assert_eq!( + user_agents(&headers), + vec![expected_user_agent().as_str()], + "the device-session refresh identifies the crate" + ); + assert_eq!( + body, b"grant_type=refresh_token&client_id=cli&refresh_token=rt", + "an absent device id is omitted, not sent empty" + ); + } + + #[tokio::test] + async fn refresh_classifies_the_oauth_error_body() { + for (error, check) in [ + ( + "invalid_grant", + (|e| matches!(e, AuthError::InvalidGrant(_))) as fn(&AuthError) -> bool, + ), + ("invalid_client", |e| { + matches!(e, AuthError::InvalidClient(_)) + }), + ("access_denied", |e| matches!(e, AuthError::AccessDenied(_))), + ] { + let body: &'static str = match error { + "invalid_grant" => r#"{"error":"invalid_grant"}"#, + "invalid_client" => r#"{"error":"invalid_client"}"#, + _ => r#"{"error":"access_denied"}"#, + }; + let transport: SharedTransport = Arc::new(Stub::replying(400, body)); + let err = Token::refresh_with( + &transport, + &SecretToken::new("rt"), + &base_url(), + "cli", + None, + ) + .await + .unwrap_err(); + assert!(check(&err), "{error}: {err:?}"); + } + } + + #[tokio::test] + async fn access_key_posts_json_and_maps_the_response() { + let stub = Arc::new(Stub::replying( + 200, + r#"{"accessToken":"svc","expiry":4102444800}"#, + )); + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + base_url(), + Some("aud".into()), + stub.clone(), + ); + + let token = refresher.refresh(&()).await.unwrap(); + + assert_eq!(token.access_token().as_str(), "svc"); + let (method, url, headers, body) = seen(&stub); + assert_eq!(method, "POST"); + assert_eq!(url, "https://cts.example.com/api/authorise"); + assert!(headers.contains(&("content-type".to_string(), "application/json".to_string()))); + assert_eq!( + user_agents(&headers), + vec![expected_user_agent().as_str()], + "the access-key exchange identifies the crate" + ); + assert_eq!( + serde_json::from_slice::<serde_json::Value>(&body).unwrap(), + serde_json::json!({"accessKey": "CSAKid.secret", "audience": "aud"}) + ); + } + + #[tokio::test] + async fn oidc_federation_posts_json_and_identifies_the_crate() { + let stub = Arc::new(Stub::replying( + 200, + r#"{"accessToken":"svc","expiry":4102444800}"#, + )); + let provider = OidcProviderFn::new(|| async { Ok(SecretToken::new("h.p.s")) }); + let refresher = OidcRefresher::new(provider, workspace_id(), base_url(), stub.clone()); + + let _ = refresher.refresh(&()).await; + + let (method, url, headers, _) = seen(&stub); + assert_eq!(method, "POST"); + assert_eq!(url, "https://cts.example.com/api/authorise"); + assert_eq!( + user_agents(&headers), + vec![expected_user_agent().as_str()], + "the OIDC federation exchange identifies the crate" + ); + } + + #[tokio::test] + async fn a_bare_402_is_a_usage_limit_on_every_exchange() { + let transport: SharedTransport = Arc::new(Stub::replying(402, "")); + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + base_url(), + None, + transport, + ); + let err = refresher.refresh(&()).await.unwrap_err(); + assert!(matches!(err, AuthError::UsageLimitExceeded(_)), "{err:?}"); + + let transport: SharedTransport = Arc::new(Stub::replying(402, "")); + let provider = OidcProviderFn::new(|| async { Ok(SecretToken::new("h.p.s")) }); + let refresher = OidcRefresher::new(provider, workspace_id(), base_url(), transport); + let err = refresher.refresh(&()).await.unwrap_err(); + assert!(matches!(err, AuthError::UsageLimitExceeded(_)), "{err:?}"); + + let transport: SharedTransport = Arc::new(Stub::replying(402, "")); + let err = Token::refresh_with( + &transport, + &SecretToken::new("rt"), + &base_url(), + "cli", + None, + ) + .await + .unwrap_err(); + assert!(matches!(err, AuthError::UsageLimitExceeded(_)), "{err:?}"); + } + + #[tokio::test] + async fn an_unclassified_failure_is_a_server_error_with_the_body() { + let transport: SharedTransport = Arc::new(Stub::replying(500, "boom")); + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + base_url(), + None, + transport, + ); + let err = refresher.refresh(&()).await.unwrap_err(); + match err { + AuthError::Server(e) => { + assert!(e.to_string().contains("500") && e.to_string().contains("boom")) + } + other => panic!("{other:?}"), + } + } + + #[tokio::test] + async fn a_transport_failure_is_a_request_error() { + let transport: SharedTransport = Arc::new(Stub::failing("connection refused")); + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + base_url(), + None, + transport, + ); + let err = refresher.refresh(&()).await.unwrap_err(); + match err { + AuthError::Request(e) => assert!(e.to_string().contains("connection refused")), + other => panic!("{other:?}"), + } + } + + #[tokio::test] + async fn an_undecodable_success_body_is_a_request_error() { + let transport: SharedTransport = Arc::new(Stub::replying(200, "not json")); + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + base_url(), + None, + transport, + ); + let err = refresher.refresh(&()).await.unwrap_err(); + assert!(matches!(err, AuthError::Request(_)), "{err:?}"); + } + + #[cfg(not(feature = "http"))] + #[test] + fn a_builder_without_a_transport_is_refused_when_there_is_no_bundled_one() { + let crn: cts_common::Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); + let key: crate::AccessKey = "CSAKtestKeyId.testKeySecret".parse().unwrap(); + let Err(err) = crate::AccessKeyStrategy::new(crn, key) else { + panic!("built a strategy with nothing to send through"); + }; + assert!(matches!(err, AuthError::Request(_)), "{err:?}"); + assert!(err.to_string().contains("`.transport(..)`"), "{err}"); + } + + #[cfg(not(feature = "http"))] + #[test] + fn a_builder_with_a_transport_builds_without_the_bundled_one() { + let crn: cts_common::Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); + let key: crate::AccessKey = "CSAKtestKeyId.testKeySecret".parse().unwrap(); + assert!(crate::AccessKeyStrategy::builder(crn, key) + .transport(Stub::replying(200, "")) + .build() + .is_ok()); + } +} diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml new file mode 100644 index 000000000..44774dc25 --- /dev/null +++ b/packages/stack-auth/tasks.toml @@ -0,0 +1,71 @@ +# Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc +# warning fails the build. Doc *examples* are `test:doc:stack-auth`. Both run +# with all features so nothing feature-gated goes unchecked; the root `doc` +# task fans out over every `doc:<crate>`. +["doc:stack-auth"] +description = "Build docs for stack-auth with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-auth --no-deps --all-features" + +["test:doc:stack-auth"] +description = "Run documentation tests for stack-auth" +run = "mise x --env test -- cargo test -p stack-auth --doc --all-features" + +["test:integration:stack-auth"] +description = "Run stack-auth Node.js integration tests" +dir = "{{config_root}}/languages/typescript/packages/auth" +run = [ + "cargo build -p stack-auth-node", + "cp ../../../../target/debug/libstack_auth_node.dylib stack-auth-node.node 2>/dev/null || cp ../../../../target/debug/libstack_auth_node.so stack-auth-node.node", + "pnpm exec vitest run", +] + +["crap:stack-auth"] +description = "Gate stack-auth on the CRAP (Change Risk Anti-Patterns) metric — fails when a complex, under-tested function exceeds the threshold" +run = [ + # Instrument and run the unit tests, emitting LCOV coverage that `cargo crap` consumes. + # `stress_tests` are excluded: they are wall-clock timing/concurrency tests (real + # axum servers, real sleeps, sub-second expiry windows) that become flaky under + # llvm-cov's ~2-5x slowdown. They still run in the normal `cargo nextest` gate, and + # excluding them here has zero effect on the report — the deterministic unit tests + # already cover the same lines. + "mise x --env test -- cargo llvm-cov nextest -p stack-auth --all-features -E 'not test(stress_tests)' --lcov --output-path {{config_root}}/target/stack-auth-lcov.info", + # Score every production function. Excludes test code, examples and the node/wasm + # binding crates so the report reflects the core library. `--fail-above` exits + # non-zero when any function's CRAP score exceeds the threshold from the + # workspace-root .cargo-crap.toml (30), so this gates both local runs and CI. + # NOTE: this must stay the LAST command — the crap-stack-auth.yml CI workflow runs + # this task and relies on mise appending its trailing args (e.g. --format github) + # to this `cargo crap` invocation. + "mise x --env test -- cargo crap --path packages/stack-auth --lcov {{config_root}}/target/stack-auth-lcov.info --exclude 'node/**' --exclude 'wasm/**' --exclude 'examples/**' --exclude '**/tests.rs' --exclude '**/tests/**' --fail-above", +] + +# Fuzz stack-auth's public AccessKey string parser (libFuzzer via cargo-fuzz). +# Requires the nightly toolchain. Runs 60s by default; override by appending a +# libFuzzer flag, e.g. `mise run fuzz:access-key -- -max_total_time=300` (the +# last repeated value wins). `--sanitizer none` is safe — the parser is pure +# safe Rust. `--target $(rustc … host)` forces the native host triple (the +# cargo-fuzz binary may be x86_64 under Rosetta on Apple Silicon, which +# otherwise misdetects the target). The fuzz crate lives in +# `packages/stack-auth/fuzz/` (detached). +["fuzz:access-key"] +description = "Fuzz stack-auth's AccessKey string parser (libFuzzer, nightly, 60s default)" +dir = "{{config_root}}/packages/stack-auth" +run = "mise x --env test -- cargo +nightly fuzz run access_key_parse --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" + +# Fuzz the JWT claims decode path (Token::fuzz_decode_claims, behind the `fuzz` +# feature). On native this is the jsonwebtoken-based decode with signature +# validation disabled. Same flags/rationale as fuzz:access-key. +["fuzz:jwt-decode"] +description = "Fuzz stack-auth's JWT claims decode path (libFuzzer, nightly, 60s default)" +dir = "{{config_root}}/packages/stack-auth" +run = "mise x --env test -- cargo +nightly fuzz run jwt_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" + +# Mutation testing (cargo-mutants) over the whole crate: what the per-PR +# `--in-diff` gate (.github/workflows/mutants.yml) does for changed lines only. +# Reads .cargo/mutants.toml (all features, nextest, the slow-test filter, +# timeouts). About 15 minutes with four jobs on a laptop. The +# stress tests are filtered out of the per-mutant run by the shared config. +["mutants:stack-auth"] +description = "Full mutation-testing sweep of stack-auth (cargo-mutants; ~15 min)" +run = "mise x --env test -- cargo mutants -p stack-auth --jobs 4" diff --git a/packages/stack-encrypt-derive/Cargo.toml b/packages/stack-encrypt-derive/Cargo.toml new file mode 100644 index 000000000..a1fde854a --- /dev/null +++ b/packages/stack-encrypt-derive/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "stack-encrypt-derive" +description = "Derive macros for stack-encrypt's target-directed encryption" +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" +# Not yet released, like stack-encrypt (which is the only crate that should +# depend on this one: the macros are re-exported from there). +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "3", features = ["full", "extra-traits"] } + +# The dev-dependencies below are used only by the crate-level doctests. +# `cargo-udeps` cannot see into rustdoc, so it reports them as unused. +[package.metadata.cargo-udeps.ignore] +development = ["stack-encrypt", "stack-kms", "tokio"] + +[dev-dependencies] +# The crate-level examples are real doctests, run against the fake key +# source. A dev-dependency cycle back to `stack-encrypt` is the usual shape +# for a derive crate (cf. serde_derive -> serde). +stack-encrypt = { path = "../stack-encrypt" } +stack-kms = { path = "../stack-kms", features = ["test-support"] } +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/packages/stack-encrypt-derive/LICENSE b/packages/stack-encrypt-derive/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-encrypt-derive/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + +<https://polyformproject.org/licenses/internal-use/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-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md new file mode 100644 index 000000000..93d6fac9c --- /dev/null +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -0,0 +1,167 @@ +# Attributes + +All attributes live under `#[stash(...)]`. + +## On the struct + +| Attribute | Effect | +|---|---| +| `plaintext = Type` | The record is an encrypted form of `Type`, every field derived from the whole value. Repeatable: one impl per listed type. Omit it for an impl generic over the plaintext (see below). | +| `struct = Type` | The record encrypts the struct `Type` field by field: every derived field is derived from the plaintext field of its own name, under the context `"<context>/<field>"` (see [Structs, field by field](#structs-field-by-field)). Exclusive with `plaintext`; requires `context`. | +| `context = "..."` | With `struct` only: the first half of every field's inferred context — the stored data's name. Required, never inferred from the type's name, and must not be empty. | +| `context_type = Type` | The record's associated `Context`, for a record whose fields take the caller's context: what the caller passes. Defaults to `CallerContext`; `AeadContext` for a record made only of ciphertexts, so an `IntoAad`-only context type is accepted (see [Which context a record takes](#which-context-a-record-takes)). Not with `context_field` or `struct`. | +| `crate = "path"` | Where to find `stack_encrypt` in the generated code (default `::stack_encrypt`), for use through a re-export. | + +`plaintext` must be an owned type: the generated impl has no lifetime to give +a reference. Without `plaintext`, each derive emits one impl generic over the plaintext, +bounded by what the fields accept: `EncryptedAge` below is `EncryptFrom<P>` +for any `P` that both `StackCipherText` and `EqualityTerm` accept, and +`DecryptInto<P>` for any `P` its `decrypt` field opens to. With +`plaintext`, the record accepts only the listed types (a field that holds +integers should not accept a `String`). Whether `Type` is a struct makes no +difference to `plaintext`: the derive sees a name, not a definition, and +derives every field from the whole value. Encrypting a struct field by +field is `struct = Type`, which the plaintext is rebuilt from with a struct +literal. + +## On a field + +| Attribute | Effect | +|---|---| +| `context_field` | Store the caller’s typed context here and recover it on decryption. Exactly one per record; excludes the other field attributes and literal contexts. | +| `context = "..."` | Derive this field under exactly this context, extended by the one the caller passes for the record like any other. A query-side term built under the same literal — extended the same way — matches it. Must not be empty. | +| `from = field` / `from = 0` | With `struct` only: derive this field from `plaintext.field` (or `plaintext.0` for a tuple struct) when its name differs from its plaintext field's. | +| `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | +| `decrypt` | Decryption opens this field (`DecryptInto` only). Needed only when the field types cannot decide it — see below. | +| `nested` | With `struct` only: infer no context for this field — it is handed the caller's context as it is, which its type (a nested `struct` derive carrying its own contexts) composes with them. Excludes `context`. | + +Each derive emits one declaration per plaintext, with an associated `Context`. +A record with `#[stash(context_field)]` on a field of type `T` requires +`NonEmpty<T>` for encryption and stores its inner value in that field. Decryption +takes `ExpectedContext<T>`. Its default checks only that the stored value is nonempty and then opens the record under whatever context it stores — so a ciphertext moved together with its stored context opens as if it belonged where it now sits. `NonEmpty<T>.into()` names the destination the caller believes it is opening; a stored context that differs is refused with `Error::ContextMismatch` before any key is retrieved. Either way the stored context is data the record arrived with, not something the cipher has authenticated. +`T` supplies the Vitamin C context encodings and implements `Clone`, +`MaybeEmpty`, and `PartialEq`. This metadata is not a separate encrypted field. +It cannot be combined with literal context attributes. + +Otherwise, a record whose fields all carry their own contexts (including a +`struct` derive) uses `DeclaredContext`. `().into()` or its default selects the +declared contexts unchanged; a nonempty caller context extends each base context. +A record with fields that need a caller context uses `CallerContext`, constructed +from a `NonEmpty<T>` or a supported integer. Both encodings and the descriptor's +structured identity are preserved when borrowing context data is converted into +an owned declaration. No context is inferred from a Rust type's name. + +## Which context a record takes + +`CallerContext` holds both of Vitamin C's encodings of the context — the AEAD +one a ciphertext is sealed under and the PRF one a term is derived under — so +building it needs a `T` that is both `IntoAad` and `IntoPrfContext`. A record +made only of ciphertexts needs only the first, and the leaf it wraps +(`StackCipherText`) asks for only that: its context is `AeadContext`. Such a +record says so with `#[stash(context_type = AeadContext)]`, and then accepts +every context the canonical `keyset.encrypt(value, context)` path accepts, +including a type that implements `IntoAad` alone: + +```rust,ignore +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = String, context_type = AeadContext)] +struct SealedName { + c: StackCipherText, +} + +let record: SealedName = name.encrypt_into_with_context(&keyset, NonEmpty::new(tenant)?).await?; +``` + +The derive cannot pick this for you: it sees the field types' names, not +what they declare. The type you name must convert into every field's own +`Context` (`AeadContext` does not convert into `CallerContext`, so a term +field beside it is a compile error at the record, which is the point), and a +field with a `context = ".."` of its own is derived under that literal +extended by the caller's context through the type's `extend` — `AeadContext` +and `CallerContext` both have one. A record with a `context_field`, or a +`struct` derive, settles its context itself and refuses the attribute. + +A declaration is executed by `keyset.encrypt_as(&value, context)` and +`cipher.decrypt_as(record, context)`, or through the blanket `EncryptInto` and +`DecryptFrom` traits for the source-side spelling (`value.encrypt_into(&keyset)`, +`record.decrypt_into(&cipher, context)`). A hand-written target implements the +same two declaration methods the derives emit, `EncryptFrom::encryption` and +`DecryptInto::decryption`; neither receives the plaintext or a cipher. + +Every attribute except `plaintext` is singular, and repeating one is a +compile error rather than a silent overwrite (`plaintext` is repeatable, +but each listed type only once). + +## Structs, field by field + +A `struct = ..` derive needs no attribute on its fields. With +`#[stash(struct = User, context = "users")]`, a field `age` is derived from +`user.age` under the context `"users/age"`; a field `email` from `user.email` +under `"users/email"`; a tuple struct's `.0` under `"users/0"`. The first +half is the container's `context` and the second the *plaintext* field's +name, so `#[stash(from = email_address)] email: ..` is derived under +`"users/email_address"`: both halves name the stored field, not the +encrypted struct. Nothing is pluralised or otherwise guessed. `context = +".."` on a field is taken verbatim and replaces the inferred one; `nested` +on a field infers none — the field is handed the caller's context as it is, +which a nested `struct` derive (carrying its own contexts) composes with +them and a leaf accepts only as a `NonEmpty<T>`. + +A context passed by the caller extends every field's: under +`user.encrypt_into_with_context(&keyset, 7u64)` the `age` field is derived +under `("users/age", 7u64)`, and a query site probes it under +`nonempty!("users/age").with(7u64)`. This is how a field is bound to its +record as well as its name — a record id, say — without the type having to +know the id. Decryption takes the same extension. The extension may be borrowed at the call site: its Vitamin C encodings are +owned by the declaration before execution. + +The context is part of the stored data's identity: it is the AAD of every +ciphertext in the column and the domain of every term. That is why the prefix +is required and explicit rather than inferred from the Rust type's name: two +plaintext types with the same name in different modules would otherwise +silently share every column context — equal plaintexts would produce +identical index terms across their tables, and ciphertexts would be +transplantable between them — and a rename would silently change the AAD of +everything stored. The field half *is* inferred from the plaintext field's +name, so renaming a plaintext field still changes that field's context and +stored data stops decrypting — `Error::Kms` against ZeroKMS, which refuses +the key retrieval under the changed descriptor before the AEAD runs, and +`Error::Aead` under a key source that ignores descriptors, such as the fake +one in tests — silently at the call site, with no compile-time signal. +Before such a rename, pin the old value with `context = ".."` on the fields +it reaches. + +A `struct` derive has no field derived from the whole plaintext, and a +`plaintext` record has none derived from a field of it: `from` and `nested` +exist only with `struct`, and the two container attributes are exclusive. + +## Which field decryption opens + +`DecryptInto` does not need to be told: every field type says whether it is a +ciphertext or a one-way index term (`Decryptable`), and the derive requires +exactly one ciphertext — among all derived fields for a record, or among the +fields derived from each plaintext field for a `struct` derive. Too few or +too many is a compile error at the record's definition (at its first use, if +the record is generic). A derived record is itself `Decryptable` if any of +its fields is, so records nest in structs without ceremony; a type of your own +implements `Decryptable` and `DecryptField` by hand. + +`decrypt` is the override for the shapes the types cannot settle: two +ciphertexts of which one is to be opened, or a field type that is not +`Decryptable`. Once any field is marked, only the marked fields are +considered — one opened as the whole plaintext, or several with `from = ..` +rebuilding the plaintext field by field — and the field types need not be +`Decryptable`. The record's own `Decryptable` impl (emitted by +`#[derive(EncryptFrom)]`) is then `true` outright — the marker says +decryption opens the record — so a marked record still nests in structs. + +`DecryptInto` consumes the record, moving each opened field out of `self`, so +the record must not implement `Drop` (including via `ZeroizeOnDrop`); wrap the +fields that need zeroizing instead. + +Terms open nothing, so a context handed to a term field on decrypt is +checked for nothing; the ciphertext field is what authenticates, under its +own context extended with the caller's exactly as it was sealed. A record +sealed with `encrypt_into` opens with `decrypt_from` and not under any +`NonEmpty<T>`; one sealed under an extension opens only under the same +extension. diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs new file mode 100644 index 000000000..0caf6183b --- /dev/null +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -0,0 +1,301 @@ +//! Parsing of the `#[stash(...)]` container and field attributes. + +use syn::{Attribute, Expr, LitStr, Member, Path, Result, Type}; + +/// Container-level options, from `#[stash(...)]` on the struct itself. +pub(crate) struct ContainerAttrs { + /// Path to the `stack_encrypt` crate in the generated code. Defaults to + /// `::stack_encrypt`; overridden by `#[stash(crate = "...")]` so the + /// macros work through a re-export. + pub(crate) krate: Path, + /// The plaintext types this record is an encrypted form of, one impl + /// each, from repeated `#[stash(plaintext = Type)]`. Empty means + /// a single impl generic over the plaintext. + pub(crate) plaintexts: Vec<Type>, + /// `#[stash(struct = Type)]`: the record encrypts the struct `Type` + /// field by field. Every derived field is derived from the plaintext + /// field of its own name (`from`), under a context made of the + /// container's `context` and the plaintext field's name, unless the + /// field says otherwise. Exclusive with `plaintext`; requires `context`. + pub(crate) by_field: Option<Type>, + /// `#[stash(context = "...")]` on the container: the first half of every + /// field's inferred context — `"<context>/<field>"`. Names the stored + /// data, not the Rust type: it is part of the stored data's identity, so + /// it is given explicitly rather than inferred from a name a refactor + /// can change. Only meaningful with `struct`. + pub(crate) context: Option<LitStr>, + /// `#[stash(context_type = Type)]`: the record's associated `Context`, + /// when its fields take the caller's context. The default, + /// `CallerContext`, derives ciphertext and terms alike and so needs both + /// Vitamin C encodings; `AeadContext` needs only the AEAD one, and a + /// record whose fields are all ciphertexts declares it to accept the + /// same contexts the canonical `StackCipherText` path does. Excludes + /// `context_field` and `struct`. + pub(crate) context_type: Option<Type>, +} + +const CONTAINER_KEYS: &str = "unsupported container attribute; expected `plaintext = Type`, \ + `struct = Type`, `context = \"...\"` (with `struct`), `context_type = Type` or \ + `crate = \"...\"`"; + +impl ContainerAttrs { + pub(crate) fn parse(attrs: &[Attribute]) -> Result<Self> { + let mut krate: Option<Path> = None; + let mut plaintexts: Vec<Type> = Vec::new(); + let mut by_field: Option<Type> = None; + let mut context: Option<LitStr> = None; + let mut context_type: Option<Type> = None; + + for attr in attrs.iter().filter(|a| a.path().is_ident("stash")) { + // `struct` and `crate` are keywords, but a nested-meta path is + // parsed with `Ident::parse_any`, so `struct = User` reads as + // written — no `r#struct`. + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("crate") { + if krate.is_some() { + return Err(meta.error("`crate` is given twice")); + } + let lit: LitStr = meta.value()?.parse()?; + krate = Some(lit.parse()?); + return Ok(()); + } + if meta.path.is_ident("context") { + if context.is_some() { + return Err(meta.error("`context` is given twice; a struct has one prefix")); + } + context = Some(meta.value()?.parse()?); + return Ok(()); + } + if meta.path.is_ident("context_type") { + if context_type.is_some() { + return Err(meta.error( + "`context_type` is given twice; a record has one associated context", + )); + } + context_type = Some(meta.value()?.parse()?); + return Ok(()); + } + if meta.path.is_ident("struct") { + if by_field.is_some() { + return Err(meta.error( + "`struct` is given twice; a record encrypts one plaintext struct", + )); + } + let ty: Type = meta.value()?.parse()?; + // The plaintext is reached by field name and rebuilt + // with a struct literal, so the type must be a struct + // named directly. + let named_struct = match &ty { + Type::Path(path) => path.qself.is_none(), + _ => false, + }; + if !named_struct { + return Err(syn::Error::new_spanned( + &ty, + "`struct` must name a struct directly (`struct = User`): its fields \ + are reached by name and the plaintext is rebuilt with a struct \ + literal", + )); + } + by_field = Some(ty); + return Ok(()); + } + if meta.path.is_ident("plaintext") { + let plaintext: Type = meta.value()?.parse()?; + // The type is spliced into the impl header as + // written, where a reference has no lifetime to + // name. The generic impl (no `plaintext` at all) + // already accepts `&str` and friends. + if let Type::Reference(_) = plaintext { + return Err(syn::Error::new_spanned( + &plaintext, + "`plaintext` must be an owned type: a reference plaintext has no \ + lifetime the generated impl can name. Omit `plaintext` for an impl \ + generic over the source, which accepts references too.", + )); + } + if plaintexts.contains(&plaintext) { + return Err(syn::Error::new_spanned( + &plaintext, + "this `plaintext` is listed twice; each listed type gets one impl", + )); + } + plaintexts.push(plaintext); + return Ok(()); + } + Err(meta.error(CONTAINER_KEYS)) + })?; + } + + if let (Some(by_field), Some(plaintext)) = (&by_field, plaintexts.first()) { + let mut err = syn::Error::new_spanned( + by_field, + "`struct` and `plaintext` are two ways of naming the plaintext: `struct = ..` \ + encrypts it field by field, `plaintext = ..` as one value, so give one of them", + ); + err.combine(syn::Error::new_spanned(plaintext, "`plaintext` given here")); + return Err(err); + } + + // The prefix is part of the stored data's identity — the AAD of every + // ciphertext derived from the struct and the domain of every term — + // so it is never inferred from the Rust type's name: two types named + // `Account` in different modules would silently share every field + // context, making ciphertexts transplantable between them and index + // terms comparable across them. + match (&by_field, &context) { + (Some(by_field), None) => { + return Err(syn::Error::new_spanned( + by_field, + "`struct = ..` needs a `context = \"..\"` beside it naming the stored data \ + (e.g. `#[stash(struct = User, context = \"users\")]`): each field is derived \ + under `\"<context>/<field>\"`, and the prefix is part of the stored data's \ + identity, so it is given explicitly rather than inferred from the Rust \ + type's name", + )); + } + (None, Some(context)) => { + return Err(syn::Error::new( + context.span(), + "a container `context` is the prefix of the per-field contexts and applies \ + only with `struct = ..`; a `plaintext` record's fields take the caller's \ + context, or a `context = \"..\"` of their own", + )); + } + _ => {} + } + if let Some(context) = &context { + if context.value().is_empty() { + return Err(syn::Error::new( + context.span(), + "an empty `context` is rejected when a value is encrypted: name the stored \ + data (e.g. \"users\")", + )); + } + } + + if let (Some(by_field), Some(context_type)) = (&by_field, &context_type) { + let mut err = syn::Error::new_spanned( + context_type, + "`context_type` names what the caller passes to a record whose fields take the \ + caller's context; a `struct` derive's fields carry their own, so the record \ + takes `DeclaredContext` and a caller's context extends them", + ); + err.combine(syn::Error::new_spanned(by_field, "`struct` given here")); + return Err(err); + } + + Ok(Self { + krate: krate.unwrap_or_else(|| syn::parse_quote!(::stack_encrypt)), + plaintexts, + by_field, + context, + context_type, + }) + } +} + +/// Field-level options, from `#[stash(...)]` on a field. +#[derive(Default)] +pub(crate) struct FieldAttrs { + pub(crate) context_field: bool, + /// `#[stash(context = "...")]`: derive this field under exactly this + /// context instead of the one a `struct` derive would infer, or the one + /// the caller passes for the record. Extended by a caller's context like + /// any other. + pub(crate) context: Option<LitStr>, + /// `#[stash(from = field)]` / `#[stash(from = 0)]`: with `struct = ..`, + /// derive this field from a plaintext field whose name differs from its + /// own. + pub(crate) from: Option<Member>, + /// `#[stash(default)]` / `#[stash(default = expr)]`: not derived; + /// filled with `Default::default()` or the expression. + pub(crate) default: Option<Option<Expr>>, + /// `#[stash(decrypt)]`: decryption opens this field. + pub(crate) decrypt: bool, + /// `#[stash(nested)]`: with `struct = ..`, do not infer a context for + /// this field — hand it the caller's as it is, because its type (a + /// nested `struct` derive) carries its own contexts and composes them + /// with it. + pub(crate) nested: bool, +} + +impl FieldAttrs { + pub(crate) fn parse(attrs: &[Attribute]) -> Result<Self> { + let mut parsed = Self::default(); + + for attr in attrs.iter().filter(|a| a.path().is_ident("stash")) { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("context_field") { + if parsed.context_field { + return Err(meta.error("`context_field` is given twice")); + } + parsed.context_field = true; + return Ok(()); + } + if meta.path.is_ident("context") { + // Each of these is singular by meaning, so a repeat is a + // mistake: rejected rather than silently overwritten. A + // silently-winning second `from` would be the worst of + // them — it crosses fields, which is exactly the failure + // the derive exists to prevent. + if parsed.context.is_some() { + return Err(meta.error("`context` is given twice; a field has one context")); + } + parsed.context = Some(meta.value()?.parse()?); + return Ok(()); + } + if meta.path.is_ident("from") { + if parsed.from.is_some() { + return Err(meta.error( + "`from` is given twice; a field is derived from one plaintext field", + )); + } + parsed.from = Some(meta.value()?.parse()?); + return Ok(()); + } + if meta.path.is_ident("default") { + if parsed.default.is_some() { + return Err(meta.error("`default` is given twice")); + } + parsed.default = Some(if meta.input.peek(syn::Token![=]) { + Some(meta.value()?.parse()?) + } else { + None + }); + return Ok(()); + } + if meta.path.is_ident("decrypt") { + if parsed.decrypt { + return Err(meta.error("`decrypt` is given twice")); + } + parsed.decrypt = true; + return Ok(()); + } + if meta.path.is_ident("nested") { + if parsed.nested { + return Err(meta.error("`nested` is given twice")); + } + parsed.nested = true; + return Ok(()); + } + Err(meta.error( + "unsupported field attribute; expected `context_field`, `context = \"...\"`, \ + `from = field`, `default`, `default = expr`, `decrypt` or `nested`", + )) + })?; + } + + if parsed.nested { + if let Some(context) = &parsed.context { + return Err(syn::Error::new( + context.span(), + "`nested` hands this field the caller's context because its type carries its \ + own, so `context` does not apply: give one or the other", + )); + } + } + + Ok(parsed) + } +} diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs new file mode 100644 index 000000000..b274411dc --- /dev/null +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -0,0 +1,311 @@ +//! Emit ciphertext inspection and core-owned opening descriptions. +use crate::shape::{trait_impl, zip, Field, Record}; +use proc_macro2::{Span, TokenStream}; +use quote::{quote, quote_spanned, ToTokens}; +use std::collections::HashSet; +use syn::spanned::Spanned; +use syn::{parse_quote, DeriveInput, Ident, LitStr, Member, Path, PathArguments, Result, Type}; + +pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { + let record = Record::parse(&input)?; + let krate = &record.krate; + let name = &input.ident; + let explicit = record.fields.iter().any(|f| f.decrypt); + let groups = if explicit { + match Mode::classify(record.fields.iter().filter(|f| f.decrypt).collect(), name)? { + Mode::Whole(field) => vec![(None, vec![field])], + Mode::ByField(fields) => fields.into_iter().map(|f| (f.from(), vec![f])).collect(), + } + } else { + match Auto::classify(&record) { + Auto::Whole(fields) => vec![(None, fields)], + Auto::ByField(groups) => groups + .into_iter() + .map(|g| (Some(g.from), g.fields)) + .collect(), + } + }; + let checks = if explicit { + TokenStream::new() + } else { + let checks = groups + .iter() + .map(|(from, fields)| check(krate, name, *from, fields)); + quote!(#(#checks)*) + }; + let (definition_check, body_check) = if input.generics.params.is_empty() { + (quote!(const _: () = { #checks };), TokenStream::new()) + } else { + (TokenStream::new(), quote!(let () = const { #checks };)) + }; + let context = record.context_type(true); + let (plaintexts, generic) = record.sources(parse_quote!(__P)); + let mut impls = Vec::new(); + for plaintext in plaintexts { + let mut generics = input.generics.clone(); + if generic { + generics.params.push(parse_quote!(__P: 'static)); + } + generics + .make_where_clause() + .predicates + .push(parse_quote!(Self: 'static)); + let mut operations = Vec::new(); + for (index, (from, fields)) in groups.iter().enumerate() { + let output = if from.is_some() { + parse_quote!(_) + } else { + plaintext.clone() + }; + if from.is_none() { + for field in fields { + let ty = &field.ty; + let ctx = record.field_context_type(field); + let predicates = &mut generics.make_where_clause().predicates; + if explicit { + predicates.push(parse_quote!(#ty: #krate::target::DecryptInto<#output>)); + predicates.push(parse_quote!(#ctx: Into<<#ty as #krate::target::DecryptInto<#output>>::Context>)); + } else { + predicates + .push(parse_quote!(#ty: #krate::target::DecryptField<#output, #ctx>)); + } + } + } + let operation = if explicit { + let field = fields[0]; + let ty = &field.ty; + let member = &field.member; + let ctx = record.context_expr(field); + quote_spanned!(ty.span()=> <#ty as #krate::target::DecryptInto<#output>>::decryption::<__K>(self.#member, #ctx.into())) + } else { + let calls: Vec<_> = fields.iter().map(|field| { + let ty = &field.ty; let member = &field.member; + let ctx = record.context_expr(field); + let ctx_ty = record.field_context_type(field); + quote_spanned!(ty.span()=> <#ty as #krate::target::DecryptField<#output, #ctx_ty>>::decryption_field::<__K>(self.#member, #ctx)) + }).collect(); + // Evaluate every inspection before chaining: a context error belongs + // to the whole declaration, not to an Option::or_else closure. + let locals: Vec<_> = (0..calls.len()) + .map(|n| Ident::new(&format!("__candidate_{n}"), Span::call_site())) + .collect(); + let bindings = calls + .iter() + .zip(&locals) + .map(|(call, local)| quote!(let #local = #call;)); + let first = &locals[0]; + let rest = &locals[1..]; + quote!({ #(#bindings)* #first #(.or(#rest))* .unwrap_or_else(|| #krate::target::Decryption::failed(#krate::Error::NotOpened)) }) + }; + operations.push(( + operation, + Ident::new(&format!("__group_{index}"), Span::call_site()), + )); + } + let body = if groups[0].0.is_none() { + operations.remove(0).0 + } else { + let literal = struct_literal_path(&plaintext)?; + let assignments = groups + .iter() + .zip(&operations) + .map(|((from, _), (_, local))| quote!(#from: #local)); + let output = quote!(#literal { #(#assignments),* }); + zip(operations, output) + }; + let stored = record.context_field().map(|field| { + let member = &field.member; + quote!(let __context = match __context.validate(self.#member) { + Ok(context) => context, Err(error) => return #krate::target::Decryption::failed(error), + };) + }); + impls.push(trait_impl(&input, &generics, quote!(#krate::target::DecryptInto<#plaintext>), quote! { + type Context = #context; + fn decryption<__K: 'static>(self, __context: Self::Context) -> #krate::target::Decryption<#plaintext, __K> { + #body_check #stored #body + } + })); + } + let mut generics = input.generics.clone(); + generics.params.push(parse_quote!(__P)); + generics.params.push(parse_quote!(__Ctx)); + generics + .make_where_clause() + .predicates + .push(parse_quote!(Self: #krate::target::DecryptInto<__P>)); + generics + .make_where_clause() + .predicates + .push(parse_quote!(__Ctx: Into<<Self as #krate::target::DecryptInto<__P>>::Context>)); + let field = trait_impl( + &input, + &generics, + quote!(#krate::target::DecryptField<__P, __Ctx>), + quote! { + fn decryption_field<__K: 'static>(self, context: __Ctx) -> Option<#krate::target::Decryption<__P, __K>> { + Some(<Self as #krate::target::DecryptInto<__P>>::decryption(self, context.into())) + } + }, + ); + Ok(quote!(#(#impls)* #field #definition_check)) +} +fn struct_literal_path(plaintext: &Type) -> Result<Path> { + let Type::Path(type_path) = plaintext else { + return Err(syn::Error::new_spanned( + plaintext, + "field-by-field decryption rebuilds the plaintext as a struct literal, so \ + `plaintext` must name a struct", + )); + }; + if type_path.qself.is_some() { + return Err(syn::Error::new_spanned( + plaintext, + "field-by-field decryption rebuilds the plaintext as a struct literal, so \ + `plaintext` must name a struct directly, not through a qualified path", + )); + } + let mut path = type_path.path.clone(); + for segment in &mut path.segments { + if let PathArguments::AngleBracketed(args) = &mut segment.arguments { + args.colon2_token = Some(Default::default()); + } + } + Ok(path) +} + +// ============================================================================= +// Automatic mode: the type system picks the field +// ============================================================================= + +/// The shape of a record with no `decrypt` attribute, from its `from`s. +enum Auto<'a> { + /// No derived field has a `from`: one of them is the whole plaintext's + /// ciphertext. + Whole(Vec<&'a Field>), + /// Every derived field has a `from`: each plaintext field is recovered by + /// one of the fields derived from it. + ByField(Vec<Group<'a>>), +} + +/// The fields derived from one field of the plaintext. +struct Group<'a> { + from: &'a Member, + fields: Vec<&'a Field>, +} + +impl<'a> Auto<'a> { + fn classify(record: &'a Record) -> Self { + let candidates = record.derived(); + if !record.by_field { + return Auto::Whole(candidates); + } + + let mut groups: Vec<Group<'a>> = Vec::new(); + for field in candidates { + let from = field + .from() + .unwrap_or_else(|| unreachable!("every field of a `struct` derive has a `from`")); + match groups.iter_mut().find(|g| g.from == from) { + Some(group) => group.fields.push(field), + None => groups.push(Group { + from, + fields: vec![field], + }), + } + } + Auto::ByField(groups) + } +} + +fn check(krate: &Path, name: &Ident, from: Option<&Member>, fields: &[&Field]) -> TokenStream { + let terms = fields.iter().map(|field| { + let ty = &field.ty; + // Spanned at the field type: a type that is not `Decryptable` is + // reported there, not at the derive. + quote_spanned!(ty.span()=> + (<#ty as #krate::target::Decryptable>::DECRYPTABLE as usize)) + }); + let (none, several) = match from { + None => ( + format!( + "`{name}` has no decryptable field: every derived field is a one-way index \ + term, so there is nothing for DecryptInto to open" + ), + format!( + "`{name}` has several decryptable fields: mark the one decryption opens \ + `#[stash(decrypt)]`" + ), + ), + Some(from) => { + let from = from.to_token_stream(); + ( + format!( + "no field of `{name}` can recover the plaintext field `{from}`: every field \ + derived from it is a one-way index term" + ), + format!( + "several fields of `{name}` are derived from the plaintext field `{from}` and \ + decryptable: mark the one decryption opens `#[stash(decrypt)]`" + ), + ) + } + }; + let none = LitStr::new(&none, Span::call_site()); + let several = LitStr::new(&several, Span::call_site()); + // A `let`, not a nested `const` item: an item could not see the + // record's generics from inside an inline `const`. + quote! { + { + let __decryptable: usize = 0 #(#terms)*; + ::core::assert!(__decryptable >= 1, #none); + ::core::assert!(__decryptable <= 1, #several); + } + } +} + +enum Mode<'a> { + /// One field is the whole plaintext's ciphertext: decrypting the record is + /// decrypting that field. + Whole(&'a Field), + /// Each opened field recovers one field of the source, which is rebuilt + /// by name. + ByField(Vec<&'a Field>), +} + +impl<'a> Mode<'a> { + /// Which of the two shapes the `decrypt` fields describe; an error if + /// they describe neither. + fn classify(opened: Vec<&'a Field>, name: &Ident) -> Result<Self> { + let by_field = opened.iter().filter(|f| f.from().is_some()).count(); + if by_field == 0 { + if opened.len() == 1 { + return Ok(Mode::Whole(opened[0])); + } + return Err(syn::Error::new_spanned( + name, + "several fields are marked `decrypt` but the record is one value: one plaintext \ + cannot be recovered from two fields. Mark only the ciphertext field, or encrypt \ + a struct field by field with `#[stash(struct = ..)]`.", + )); + } + debug_assert_eq!( + by_field, + opened.len(), + "every field of a `struct` derive has a `from`, and no other field does" + ); + + let mut seen: HashSet<&Member> = HashSet::with_capacity(opened.len()); + for field in &opened { + let from = field + .from() + .unwrap_or_else(|| unreachable!("counted above")); + if !seen.insert(from) { + let name = quote!(#from); + return Err(syn::Error::new( + from.span(), + format!("two `decrypt` fields would recover the same plaintext field `{name}`"), + )); + } + } + Ok(Mode::ByField(opened)) + } +} diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs new file mode 100644 index 000000000..76b805c35 --- /dev/null +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -0,0 +1,107 @@ +//! Emit operation declarations; only core code receives plaintext and a cipher. +use crate::shape::{fresh_lifetime, trait_impl, zip_chain, Field, Kind, Record}; +use proc_macro2::TokenStream; +use quote::{quote, quote_spanned}; +use syn::spanned::Spanned; +use syn::{parse_quote, DeriveInput, Result}; + +pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { + let record = Record::parse(&input)?; + let krate = &record.krate; + let fields = record.derived(); + let context = record.context_type(false); + let (sources, generic) = record.sources(parse_quote!(__S)); + let source_lifetime = fresh_lifetime(&input.generics, "__source"); + let mut impls = Vec::new(); + for source in sources { + let mut generics = input.generics.clone(); + if generic { + generics.params.push(parse_quote!(__S)); + } + let predicates = &mut generics.make_where_clause().predicates; + predicates.push(parse_quote!(Self: 'static)); + // ADR-0004: one context is threaded to every field, and `zip` will + // not combine two subtrees that need different types — so every + // field's declaration is brought to the type the record's tree + // carries (`Record::threaded_context`) by `Record::field_threading`, + // and the where-clause says what that asks of the field's type. + // + // The bounds name concrete types rather than adding a fresh impl + // parameter, deliberately: a parameter constrained only by an + // associated-type binding in a where-clause is E0207, and the user + // is told "unconstrained type parameter" instead of their mistake. + for field in fields.iter().filter(|f| f.from().is_none()) { + predicates.extend(record.field_bounds(field, &source)); + } + let operations = fields.iter().map(|field| { + let ty = &field.ty; + let threading = record.field_threading(field); + // Spanned at the field type: what the field's type refuses is + // reported there, not at the derive. + let operation = if let Some(from) = field.from() { + quote_spanned!(ty.span()=> <#ty as #krate::target::EncryptFrom<_>>::encryption::<__K>() + .project(|__source: &#source| &__source.#from) #threading) + } else { + quote_spanned!(ty.span()=> <#ty as #krate::target::EncryptFrom<#source>>::encryption::<__K>() #threading) + }; + (operation, field.local.clone()) + }).collect(); + let assignments = record.fields.iter().map(|field| { + let member = &field.member; + let value = match &field.kind { + Kind::Derived { .. } => { + let local = &field.local; + quote!(#local) + } + Kind::Default(Some(expr)) => quote!(#expr), + Kind::Default(None) => quote!(::core::default::Default::default()), + Kind::Context => quote!(__context.clone().into_inner()), + }; + quote!(#member: #value) + }); + let result = quote!(Self { #(#assignments),* }); + // The tree carries the threaded context; the record declares + // `Self::Context`, converted into it once at the root — a record + // storing its own context declares the `NonEmpty<T>` it stores while + // its operations need a `CallerContext`. Such a record fills the + // stored field here too: under threading the context arrives when + // the description runs, not when it is built. + let (chain, pattern) = zip_chain(operations); + let chain = quote!(#chain.accepting::<Self::Context>()); + let body = if record.context_field().is_some() { + quote!(#chain.map_with_context(move |#pattern, __context| #result)) + } else { + quote!(#chain.map(move |#pattern| #result)) + }; + impls.push(trait_impl(&input, &generics, quote!(#krate::target::EncryptFrom<#source>), quote! { + type Context = #context; + fn encryption<#source_lifetime,__K: 'static>() -> #krate::target::Encryption<#source_lifetime,#source, Self, __K, Self::Context> where #source:#source_lifetime { + #body + } + })); + } + let decryptable = decryptable_impl(&input, &record, &fields); + Ok(quote!(#(#impls)* #decryptable)) +} +fn decryptable_impl(input: &DeriveInput, record: &Record, derived: &[&Field]) -> TokenStream { + let krate = &record.krate; + let name = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let value = if record.fields.iter().any(|f| f.decrypt) { + quote!(true) + } else { + let terms = derived.iter().map(|field| { + let ty = &field.ty; + // Spanned at the field type: a type that is not `Decryptable` + // is reported there, not at the derive. + quote_spanned!(ty.span()=> || <#ty as #krate::target::Decryptable>::DECRYPTABLE) + }); + quote!(false #(#terms)*) + }; + quote! { + #[automatically_derived] + impl #impl_generics #krate::target::Decryptable for #name #ty_generics #where_clause { + const DECRYPTABLE: bool = #value; + } + } +} diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs new file mode 100644 index 000000000..06ae7718f --- /dev/null +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -0,0 +1,181 @@ +//! Derive operation declarations for encrypted records. `EncryptFrom<P>` +//! declares how to produce a target; `DecryptInto<P>` selects ciphertext for +//! recovery. Stack Encrypt owns execution through Vitamin C's plaintext traits. +//! Generated code receives context and encrypted outputs, never a cipher. +//! +//! ``` +//! use stack_encrypt::{EncryptFrom, DecryptInto, StackCipher, StackCipherText, nonempty}; +//! use stack_encrypt::sem::EqualityTerm; +//! use stack_kms::FakeDataKeySource; +//! +//! #[derive(EncryptFrom, DecryptInto)] +//! #[stash(plaintext = String)] +//! struct TextEq { +//! #[stash(context_field)] +//! identifier: &'static str, +//! c: StackCipherText, +//! hm: EqualityTerm, +//! } +//! +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let keyset = cipher.default_keyset(); +//! let value = "alice@example.com".to_owned(); +//! // The output type selects ciphertext + equality; the context is NonEmpty<&str>, stored in `identifier`. +//! let encrypted: TextEq = keyset.encrypt_as(&value, nonempty!("users/email")).await?; +//! // Naming the destination requires the stored identifier to equal it before any key is retrieved. `ExpectedContext::default()` would check only that it is nonempty and open under it as stored. +//! let opened: String = cipher.decrypt_as(encrypted, nonempty!("users/email").into()).await?; +//! assert_eq!(opened, value); +//! # Ok::<(), Box<dyn std::error::Error>> (()) +//! # }).unwrap(); +//! ``` +//! +//! For a record without a context field, fields use the caller's context or a +//! declared literal. A record made only of ciphertexts can declare +//! `context_type = AeadContext` and accept a context type that implements +//! `IntoAad` alone, as the ciphertext leaf itself does. +//! `struct = User, context = "users"` selects plaintext fields +//! and binds them under `"users/<field>"`. The storage envelope itself adds no +//! cryptographic map-entry context. Vitamin C still binds keys inside plaintext +//! maps and preserves authenticated absence and empty-container markers. +//! +//! Ciphertext and term operations compose before awaiting, preserving batched +//! key requests. Terms alone need only their respective PRF or ordering trait. +//! Query-only targets derive `EncryptFrom` alone. +//! +//! # A field in your own storage format +//! +//! A record's fields need not be the core types. A field type that stores +//! encrypted output in its own shape declares the core operation that produces +//! it and finishes the declaration with `.transcode()`: the cipher runs the +//! operation and then hands its native output, leaf by leaf, to a +//! `Visitor` (in `stack_encrypt::target::transcode`) the field type +//! names. The visitor implements only the shapes the field stores; the trait's +//! defaults refuse every other shape with `Error::UnsupportedShape`. Nothing +//! is serialised, re-encrypted, or gathered into an intermediate tree on the +//! way, so the bytes the visitor stores are the bytes the canonical path opens. +//! +//! ``` +//! use stack_encrypt::sem::EqualityTerm; +//! use stack_encrypt::target::transcode::{Transcode, Visitor}; +//! use stack_encrypt::target::{self, CallerContext}; +//! use stack_encrypt::{ +//! CipherText, Decryptable, Encrypt, EncryptFrom, Encryption, Error, SealedValue, StackCipher, +//! nonempty, +//! }; +//! use stack_kms::FakeDataKeySource; +//! +//! /// A column that stores one sealed leaf as bytes. +//! struct LeafBytes(Vec<u8>); +//! +//! struct LeafBytesVisitor; +//! impl Visitor for LeafBytesVisitor { +//! type Value = LeafBytes; +//! // The one shape this column stores. A sequence or a map would reach a +//! // default method and be refused, never flattened. +//! fn sealed(self, leaf: SealedValue) -> Result<LeafBytes, Error> { +//! Ok(LeafBytes(leaf.to_bytes())) +//! } +//! } +//! impl Transcode for LeafBytes { +//! type Visitor = LeafBytesVisitor; +//! fn visitor() -> LeafBytesVisitor { +//! LeafBytesVisitor +//! } +//! } +//! // Whether the field holds recoverable ciphertext. The derive asks every +//! // field, so that a record can itself be a field of another record. +//! impl Decryptable for LeafBytes { +//! const DECRYPTABLE: bool = true; +//! } +//! // The declaration: the canonical ciphertext operation, read into this type. +//! // It seals under the AEAD half of the context the record threads to it. +//! impl<S: Encrypt + Clone> EncryptFrom<S> for LeafBytes { +//! type Context = CallerContext; +//! fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> +//! where +//! S: 's, +//! { +//! target::ciphertext().accepting().transcode() +//! } +//! } +//! +//! // The record uses it like any core field type. +//! #[derive(EncryptFrom)] +//! #[stash(plaintext = String)] +//! struct TextEq { +//! #[stash(context_field)] +//! identifier: &'static str, +//! c: LeafBytes, +//! hm: EqualityTerm, +//! } +//! +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let keyset = cipher.default_keyset(); +//! let value = "alice@example.com".to_owned(); +//! let context = nonempty!("users/email"); +//! let encrypted: TextEq = keyset.encrypt_as(&value, context.clone()).await?; +//! +//! // What the column holds is the leaf itself: the canonical path opens it. +//! let leaf = SealedValue::from_bytes(&encrypted.c.0)?; +//! let opened: String = cipher.decrypt(CipherText::Single(leaf), context).await?; +//! assert_eq!(opened, value); +//! # Ok::<(), Box<dyn std::error::Error>> (()) +//! # }).unwrap(); +//! ``` +//! +//! To recover the plaintext through the record rather than the canonical path, +//! the field type also implements `DecryptInto` and `DecryptField`, and the +//! record derives `DecryptInto`; the crate's `transcode` integration test shows +//! the full set. +//! +#![doc = include_str!("../docs/attributes.md")] +#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +#![deny(unsafe_code)] +#![warn( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::mem_forget, + clippy::print_stdout, + clippy::print_stderr, + clippy::dbg_macro, + clippy::todo, + clippy::unimplemented +)] +#![cfg_attr(test, allow(clippy::unwrap_used))] +#![cfg_attr(test, allow(clippy::expect_used))] +#![cfg_attr(test, allow(clippy::panic))] + +use proc_macro::TokenStream; +use syn::{parse_macro_input, DeriveInput}; + +mod attrs; +mod decrypt; +mod encrypt; +mod shape; + +/// Derive `EncryptFrom` for a record struct. See the [crate +/// documentation](crate) for what the derive emits; the attributes it accepts +/// are reproduced below. +#[doc = include_str!("../docs/attributes.md")] +#[proc_macro_derive(EncryptFrom, attributes(stash))] +pub fn derive_encrypt_from(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + encrypt::derive(input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// Derive `DecryptInto<Plaintext>` for a record struct, one impl per +/// `plaintext` type. See the [crate documentation](crate); the attributes it +/// accepts are reproduced below. +#[doc = include_str!("../docs/attributes.md")] +#[proc_macro_derive(DecryptInto, attributes(stash))] +pub fn derive_decrypt_into(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + decrypt::derive(input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs new file mode 100644 index 000000000..f61a1e2a2 --- /dev/null +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -0,0 +1,1081 @@ +//! Classification of the derive input into the record it describes. + +use proc_macro2::{Group, Span, TokenStream, TokenTree}; +use quote::{quote, quote_spanned, ToTokens}; +use syn::spanned::Spanned; +use syn::{ + parse_quote, Data, DeriveInput, Expr, Fields, Generics, Ident, Lifetime, LitStr, Member, Path, + Result, Type, WherePredicate, +}; + +use crate::attrs::{ContainerAttrs, FieldAttrs}; + +/// `tokens`, every one of them at `span`. An interpolated tree keeps the +/// spans it was built with, so a bound the derive states about a field is +/// reported at that field only if the *types* in it are spanned there too — +/// `quote_spanned!` alone re-spans nothing it interpolates. +fn respan(tokens: TokenStream, span: Span) -> TokenStream { + tokens + .into_iter() + .map(|tree| match tree { + TokenTree::Group(group) => { + let mut group = Group::new(group.delimiter(), respan(group.stream(), span)); + group.set_span(span); + TokenTree::Group(group) + } + mut leaf => { + leaf.set_span(span); + leaf + } + }) + .collect() +} + +/// A generated lifetime must not shadow one the record declares. Append +/// underscores until the name is free, preserving the user's parameters. +pub(crate) fn fresh_lifetime(generics: &Generics, base: &str) -> Lifetime { + let mut name = base.to_owned(); + while generics + .lifetimes() + .any(|declared| declared.lifetime.ident == name) + { + name.push('_'); + } + Lifetime::new(&format!("'{name}"), Span::call_site()) +} + +/// One field of a record. +#[cfg_attr(test, derive(Debug))] +pub(crate) struct Field { + /// How the field is reached (`name` or `0`); usable in a struct literal + /// either way (`Self { 0: value }` is legal Rust). + pub(crate) member: Member, + /// A local binding name, unique per field, for the generated bodies. + pub(crate) local: Ident, + pub(crate) ty: Type, + pub(crate) kind: Kind, + /// `#[stash(decrypt)]`: decryption opens this field. + pub(crate) decrypt: bool, +} + +/// How a field gets its value when the record is encrypted. +#[cfg_attr(test, derive(Debug))] +pub(crate) enum Kind { + /// Derived from the source through the field type's own `EncryptFrom`. + Derived { + /// This field's own context, if it has one: a `#[stash(context = + /// "...")]` literal, or the `"<prefix>/<field>"` a `struct` derive + /// infers. A context the caller passes extends it either way. + context: Option<LitStr>, + /// With `struct = ..`: the plaintext field this one is derived + /// from — its own name, or the `#[stash(from = field)]` override. + /// `None` for a `plaintext` record, whose fields are all derived + /// from the whole value. + from: Option<Member>, + }, + /// Not derived: `Default::default()` or the given expression. + Default(Option<Expr>), + Context, +} + +impl Field { + pub(crate) fn is_derived(&self) -> bool { + matches!(self.kind, Kind::Derived { .. }) + } + + /// The `from` member, if this is a derived field with one. + pub(crate) fn from(&self) -> Option<&Member> { + match &self.kind { + Kind::Derived { from, .. } => from.as_ref(), + Kind::Default(_) | Kind::Context => None, + } + } + + /// How this derived field gets its context — the one classification both + /// derives project their where clauses and bodies from. + /// + /// A field with a context of its own — a literal, or the one a `struct` + /// derive infers — is derived under it as it is when the caller passes + /// `()`, and under it *extended* with the caller's (`("users/age", id)`) + /// when the caller passes a `NonEmpty<_>`. A field with none is handed + /// the caller's context as it is, and its type decides what that means: + /// a nested `struct` derive composes it with its own contexts; a leaf + /// accepts it only as a `NonEmpty<_>`, so under the record's `()` impl + /// such a leaf is a compile error — at the field, since a `from` field's + /// obligation is checked in the body against the plaintext field's type + /// the derive cannot name — and the fix is a `context = ".."` on it. + /// + /// Only called for derived fields: a `default` field is not derived from + /// the source and is never handed a context at all. + pub(crate) fn field_context(&self) -> FieldContext<'_> { + match &self.kind { + Kind::Derived { + context: Some(lit), .. + } => FieldContext::Own(lit), + Kind::Derived { context: None, .. } => FieldContext::Caller, + Kind::Default(_) | Kind::Context => unreachable!("a `default` field has no context"), + } + } +} + +/// Where a derived field's context comes from. See [`Field::field_context`]. +#[cfg_attr(test, derive(Debug))] +pub(crate) enum FieldContext<'a> { + /// A context of the field's own — `#[stash(context = "...")]`, or the + /// `"<prefix>/<field>"` a `struct` derive infers: as it is under `()`, + /// extended with the caller's context under `NonEmpty<_>`. + Own(&'a LitStr), + /// No context of its own: handed the caller's as it is — `()`, or the + /// record's associated context. + Caller, +} + +/// The record a derive input describes. +#[cfg_attr(test, derive(Debug))] +pub(crate) struct Record { + pub(crate) krate: Path, + /// The plaintext types, one impl each; empty means one impl generic over + /// the plaintext. Exactly one for a `struct = ..` derive. + pub(crate) plaintexts: Vec<Type>, + /// `struct = ..`: the plaintext is encrypted field by field, every + /// derived field from one field of it (`Field::from`). + pub(crate) by_field: bool, + /// `context_type = ..`: what the caller passes, in place of the + /// `CallerContext` a record whose fields take the caller's context + /// declares by default. + pub(crate) context_type: Option<Type>, + pub(crate) fields: Vec<Field>, +} + +impl Record { + /// The fields that are derived from the plaintext, in declaration order. + pub(crate) fn derived(&self) -> Vec<&Field> { + self.fields.iter().filter(|f| f.is_derived()).collect() + } + + pub(crate) fn parse(input: &DeriveInput) -> Result<Self> { + let attrs = ContainerAttrs::parse(&input.attrs)?; + + let data = match &input.data { + Data::Struct(data) => data, + Data::Enum(_) => return Err(syn::Error::new_spanned( + &input.ident, + "EncryptFrom/DecryptInto cannot be derived for enums: a record is a fixed set of \ + fields derived from one source, and a variant choice has no field to be \ + derived into. Model the choice explicitly instead, e.g. as a struct of \ + `Option` fields.", + )), + Data::Union(_) => { + return Err(syn::Error::new_spanned( + &input.ident, + "EncryptFrom/DecryptInto cannot be derived for unions", + )) + } + }; + + // `ContainerAttrs::parse` has established that `context` is present + // exactly when `struct` is. + let fields = collect(&data.fields, attrs.context.as_ref())?; + if fields + .iter() + .filter(|f| matches!(f.kind, Kind::Context)) + .count() + > 1 + { + return Err(syn::Error::new_spanned( + &input.ident, + "a record has exactly one `context_field`", + )); + } + if fields.iter().any(|f| matches!(f.kind, Kind::Context)) && attrs.context.is_some() { + return Err(syn::Error::new_spanned( + &input.ident, + "`context_field` supplies the complete context; a literal prefix does not apply", + )); + } + if fields.iter().any(|f| matches!(f.kind, Kind::Context)) + && fields.iter().any(|f| { + matches!( + f.kind, + Kind::Derived { + context: Some(_), + .. + } + ) + }) + { + return Err(syn::Error::new_spanned(&input.ident, "`context_field` supplies the complete context; literal field contexts do not apply")); + } + let by_field = attrs.by_field.is_some(); + let plaintexts = match attrs.by_field { + Some(plaintext) => vec![plaintext], + None => attrs.plaintexts, + }; + + if !fields.iter().any(Field::is_derived) { + return Err(syn::Error::new_spanned( + &input.ident, + "nothing to derive: a record needs at least one field that is not `default`", + )); + } + + let record = Self { + krate: attrs.krate, + plaintexts, + by_field, + context_type: attrs.context_type, + fields, + }; + // `ContainerAttrs::parse` has refused `context_type` beside `struct`; + // the other two shapes that settle the context themselves are + // checked here, where the fields are known. + if let Some(context_type) = &record.context_type { + if record.context_field().is_some() { + return Err(syn::Error::new_spanned( + context_type, + "`context_field` supplies the complete context, so the record's `Context` is \ + `NonEmpty<T>` of that field's type; `context_type` does not apply", + )); + } + if record.declared_contexts() { + return Err(syn::Error::new_spanned( + context_type, + "`context_type` names what the caller passes to a record whose fields take \ + the caller's context; every field here carries a `context = \"..\"` of its \ + own, so the record takes `DeclaredContext` and a caller's context extends \ + them", + )); + } + } + Ok(record) + } +} + +pub(crate) fn trait_impl( + input: &DeriveInput, + generics: &Generics, + trait_path: TokenStream, + content: TokenStream, +) -> TokenStream { + let name = &input.ident; + let (_, ty_generics, _) = input.generics.split_for_impl(); + let (impl_generics, _, where_clause) = generics.split_for_impl(); + quote! { + #[automatically_derived] + impl #impl_generics #trait_path for #name #ty_generics #where_clause { + #content + } + } +} + +/// The fields, with what a `struct` derive (`prefix` is the container's +/// `context`) fills in: `from` is the field's own name and `context` is +/// `"<prefix>/<from>"`, each unless the field gives its own. +/// `#[stash(nested)]` opts a field out of the inferred context — it is handed +/// the caller's as it is, which a nested `struct` derive (a type carrying its +/// own contexts) composes with them and a leaf accepts only as a +/// `NonEmpty<_>`. `from` and `nested` reach into the plaintext, so they +/// exist only with `struct = ..`. +fn collect(fields: &Fields, prefix: Option<&LitStr>) -> Result<Vec<Field>> { + fields + .iter() + .enumerate() + .map(|(index, field)| { + let attrs = FieldAttrs::parse(&field.attrs)?; + let member = match &field.ident { + Some(ident) => Member::Named(ident.clone()), + None => Member::Unnamed(syn::Index::from(index)), + }; + if prefix.is_none() { + if let Some(from) = &attrs.from { + return Err(syn::Error::new( + from.span(), + "`from = ..` reaches into a field of the plaintext, which is what \ + `#[stash(struct = ..)]` does: a `plaintext` record derives every field \ + from the whole value", + )); + } + if attrs.nested { + return Err(syn::Error::new_spanned( + &field.ty, + "`nested` opts a field out of the context a `struct` derive infers, so \ + it applies only with `struct = ..`; a `plaintext` record's field with no \ + `context` is already handed the caller's", + )); + } + } + // A literal context becomes a `nonempty!(..)`, which refuses an + // empty one at compile time anyway; say so here, at the + // attribute, with the alternative that applies. + if let Some(context) = &attrs.context { + if context.value().is_empty() { + let message = if prefix.is_some() { + "an empty `context` is rejected when a value is encrypted: name the \ + field (e.g. \"users/email\"), or drop the attribute to use the inferred \ + `\"<context>/<field>\"`" + } else { + "an empty `context` is rejected when a value is encrypted: name the \ + field (e.g. \"users/email\"), or drop the attribute to hand the field \ + the caller's context" + }; + return Err(syn::Error::new(context.span(), message)); + } + } + if attrs.context_field + && (attrs.default.is_some() + || attrs.context.is_some() + || attrs.from.is_some() + || attrs.decrypt + || attrs.nested) + { + return Err(syn::Error::new_spanned( + &field.ty, + "`context_field` is metadata and cannot also be derived or defaulted", + )); + } + let kind = if attrs.context_field { + Kind::Context + } else { + match attrs.default { + Some(default) => { + if attrs.context.is_some() + || attrs.from.is_some() + || attrs.decrypt + || attrs.nested + { + return Err(syn::Error::new_spanned( + &field.ty, + "a `default` field is not derived from the source, so `context`, \ + `from`, `decrypt` and `nested` do not apply to it", + )); + } + Kind::Default(default) + } + None => match prefix { + Some(prefix) => { + let from = attrs.from.unwrap_or_else(|| member.clone()); + let context = if attrs.nested { + // The field's type carries its own contexts; it + // is handed the caller's (`FieldContext::Caller`). + None + } else if let Some(lit) = attrs.context { + Some(lit) + } else { + let column = match &from { + Member::Named(ident) => ident.to_string(), + Member::Unnamed(index) => index.index.to_string(), + }; + let prefix = prefix.value(); + Some(LitStr::new(&format!("{prefix}/{column}"), member.span())) + }; + Kind::Derived { + context, + from: Some(from), + } + } + None => Kind::Derived { + context: attrs.context, + from: None, + }, + }, + } + }; + Ok(Field { + member, + local: Ident::new(&format!("__field_{index}"), Span::call_site()), + ty: field.ty.clone(), + kind, + decrypt: attrs.decrypt, + }) + }) + .collect() +} + +impl Record { + pub(crate) fn context_field(&self) -> Option<&Field> { + self.fields.iter().find(|f| matches!(f.kind, Kind::Context)) + } + pub(crate) fn declared_contexts(&self) -> bool { + self.by_field + || self + .derived() + .iter() + .all(|f| matches!(f.field_context(), FieldContext::Own(_))) + } + pub(crate) fn context_type(&self, decrypt: bool) -> Type { + let krate = &self.krate; + if let Some(field) = self.context_field() { + let ty = &field.ty; + if decrypt { + parse_quote!(#krate::target::ExpectedContext<#ty>) + } else { + parse_quote!(#krate::NonEmpty<#ty>) + } + } else if self.declared_contexts() { + parse_quote!(#krate::target::DeclaredContext) + } else if let Some(context_type) = &self.context_type { + context_type.clone() + } else { + parse_quote!(#krate::target::CallerContext) + } + } + /// The context a field is handed on the decrypt side: the caller's as it + /// is, or — for a field with a context of its own — what `context_expr` + /// builds from the caller's: a `CallerContext` from a + /// `DeclaredContext`'s `under`, or the caller's own type from its + /// `extend`. + pub(crate) fn field_context_type(&self, field: &Field) -> Type { + let krate = &self.krate; + match field.field_context() { + FieldContext::Own(_) if self.declared_contexts() => { + parse_quote!(#krate::target::CallerContext) + } + FieldContext::Own(_) | FieldContext::Caller => self.context_type(false), + } + } + /// The context type the record's declaration tree carries on the + /// encrypt side (ADR-0004): a `DeclaredContext` when every field has a + /// context of its own, so the caller's is optional; otherwise the + /// caller's — the `context_type` named, or `CallerContext`. It is the + /// record's own `Context` except for a record that stores its context, + /// which declares the `NonEmpty<T>` it stores and converts it into this + /// once, at the root. + pub(crate) fn threaded_context(&self) -> Type { + let krate = &self.krate; + if self.context_field().is_some() { + parse_quote!(#krate::target::CallerContext) + } else { + self.context_type(false) + } + } + + /// What `under` / `extend` hand a field with a context of its own: a + /// `CallerContext` where the record makes the caller's optional + /// (`under`), the threaded context itself where it does not (`extend`). + fn own_context_extended_by(&self) -> Type { + let krate = &self.krate; + if self.declared_contexts() { + parse_quote!(#krate::target::CallerContext) + } else { + self.threaded_context() + } + } + + /// How the threaded context reaches this field's declaration, as the + /// call appended to it on the encrypt side (ADR-0004). + /// + /// The context reaches operations by being threaded, so a field names + /// itself once rather than computing a context to hand over. A field + /// with a context of its own gives its subtree that literal — `under` + /// when the record can make the caller's context optional, `extend` + /// when some other field is a bare leaf and it cannot. A field with none + /// is handed the threaded context as it is, converted into whatever its + /// type declares it needs: the AEAD half for a ciphertext, unchanged for + /// a term, composed with its own contexts by a nested record, and — for + /// a leaf reached through a record that may run under `()` — refused, + /// at the field. + /// + /// Spanned at the field type: an interpolated token stream keeps the + /// spans it was built with, so what the field's type refuses is + /// reported there rather than at the derive. + pub(crate) fn field_threading(&self, field: &Field) -> TokenStream { + let krate = &self.krate; + let span = field.ty.span(); + match field.field_context() { + FieldContext::Own(lit) => { + if self.declared_contexts() { + quote_spanned!(span=> .under(#krate::nonempty!(#lit))) + } else { + let threaded = respan(self.threaded_context().into_token_stream(), span); + quote_spanned!(span=> .extend::<#threaded>(#krate::nonempty!(#lit))) + } + } + FieldContext::Caller => { + let threaded = respan(self.threaded_context().into_token_stream(), span); + quote_spanned!(span=> .accepting::<#threaded>()) + } + } + } + + /// What [`field_threading`](Self::field_threading) asks of a field's + /// type, as the impl's where-clause: that it is a target of `source`, + /// and that the context handed down converts into the one it declares. + /// Only for a field whose source the derive can name — a `from` field's + /// obligation is checked in the body, against a plaintext field's type + /// the derive cannot name. + pub(crate) fn field_bounds(&self, field: &Field, source: &Type) -> [WherePredicate; 2] { + let krate = &self.krate; + let ty = &field.ty; + let context = quote!(<#ty as #krate::target::EncryptFrom<#source>>::Context); + let threading = match field.field_context() { + FieldContext::Own(_) => { + let extended = self.own_context_extended_by(); + parse_quote!(#context: From<#extended>) + } + FieldContext::Caller => { + let threaded = self.threaded_context(); + parse_quote!(#threaded: Into<#context>) + } + }; + [ + parse_quote!(#ty: #krate::target::EncryptFrom<#source>), + threading, + ] + } + + /// The context a field is opened under, from the record's `__context`, + /// on the decrypt side: the caller's as it is, or the field's own + /// extended by it. + pub(crate) fn context_expr(&self, field: &Field) -> TokenStream { + let krate = &self.krate; + match field.field_context() { + FieldContext::Caller => quote!(::core::clone::Clone::clone(&__context)), + FieldContext::Own(lit) => { + let method = if self.declared_contexts() { + quote!(under) + } else { + quote!(extend) + }; + quote!(::core::clone::Clone::clone(&__context).#method(#krate::nonempty!(#lit))) + } + } + } + pub(crate) fn sources(&self, generic: Type) -> (Vec<Type>, bool) { + if self.plaintexts.is_empty() { + (vec![generic], true) + } else { + (self.plaintexts.clone(), false) + } + } +} +/// The chain zipping `operations` into one description, and the nested +/// tuple pattern that binds each operation's output to its local in the +/// closure that maps the chain's output. +pub(crate) fn zip_chain(operations: Vec<(TokenStream, Ident)>) -> (TokenStream, TokenStream) { + let mut chain = TokenStream::new(); + let mut pattern = TokenStream::new(); + for (index, (operation, local)) in operations.into_iter().enumerate() { + if index == 0 { + chain = operation; + pattern = quote!(#local); + } else { + chain = quote!(#chain.zip(#operation)); + pattern = quote!((#pattern, #local)); + } + } + (chain, pattern) +} + +/// The zipped `operations`, mapped to `result`. +pub(crate) fn zip(operations: Vec<(TokenStream, Ident)>, result: TokenStream) -> TokenStream { + let (chain, pattern) = zip_chain(operations); + quote!(#chain.map(move |#pattern| #result)) +} + +#[cfg(test)] +mod tests { + use super::*; + use syn::parse_quote; + + fn parse(input: DeriveInput) -> Result<Record> { + Record::parse(&input) + } + + /// The field's own context, for assertions. + fn own(field: &Field) -> String { + match field.field_context() { + FieldContext::Own(lit) => lit.value(), + other => panic!("expected a context of the field's own, got {other:?}"), + } + } + + #[test] + fn enums_are_rejected() { + let err = parse(parse_quote! { + enum Choice { A(String), B(u32) } + }) + .unwrap_err(); + assert!(err.to_string().contains("cannot be derived for enums")); + } + + #[test] + fn all_default_is_rejected() { + let err = parse(parse_quote! { + struct Empty { + #[stash(default)] + v: u8, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("nothing to derive")); + } + + #[test] + fn from_applies_only_with_struct() { + // `from` reaches into the plaintext, which is what `struct = ..` + // means; a `plaintext` record derives every field from the whole + // value, named or not. + for input in [ + parse_quote! { + struct Row { + #[stash(from = age)] + age: EncryptedAge, + } + }, + parse_quote! { + #[stash(plaintext = User)] + struct Row { + #[stash(from = age, context = "users/age")] + age: EncryptedAge, + } + }, + ] { + let err = parse(input).unwrap_err(); + assert!( + err.to_string() + .contains("what `#[stash(struct = ..)]` does"), + "{err}" + ); + } + } + + #[test] + fn default_excludes_the_derived_attributes() { + let err = parse(parse_quote! { + struct Rec { + c: StackCipherText, + #[stash(default, context = "x")] + v: u8, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`default` field is not derived")); + } + + #[test] + fn unknown_attributes_are_rejected() { + let err = parse(parse_quote! { + struct Rec { + #[stash(rename = "x")] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("unsupported field attribute")); + + let err = parse(parse_quote! { + #[stash(source = i32)] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("unsupported container attribute")); + } + + #[test] + fn repeated_singleton_attributes_are_rejected() { + // A silently-winning second `from` would encrypt the wrong (same- + // typed) plaintext field — the crossed-field failure the derive + // exists to prevent — so every singular attribute rejects a repeat. + let err = parse(parse_quote! { + #[stash(struct = User, context = "users")] + struct Rec { + #[stash(from = expected, from = other)] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`from` is given twice")); + + let err = parse(parse_quote! { + struct Rec { + #[stash(context = "users/email", context = "users/name")] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`context` is given twice")); + + // Also across two `#[stash(..)]` attributes on the same field. + let err = parse(parse_quote! { + struct Rec { + #[stash(context = "users/email")] + #[stash(context = "users/name")] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`context` is given twice")); + + let err = parse(parse_quote! { + struct Rec { + c: StackCipherText, + #[stash(default, default = 3)] + v: u8, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`default` is given twice")); + + let err = parse(parse_quote! { + struct Rec { + #[stash(decrypt, decrypt)] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`decrypt` is given twice")); + + let err = parse(parse_quote! { + #[stash(crate = "stack_encrypt", crate = "stack_encrypt")] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`crate` is given twice")); + + let err = parse(parse_quote! { + #[stash(struct = User, struct = User, context = "users")] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`struct` is given twice")); + } + + #[test] + fn a_literal_empty_context_is_rejected_with_the_alternative_that_applies() { + let err = parse(parse_quote! { + struct Rec { + #[stash(context = "")] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("empty `context`")); + assert!(err + .to_string() + .contains("hand the field the caller's context")); + + let err = parse(parse_quote! { + #[stash(struct = User, context = "users")] + struct Rec { + #[stash(context = "")] + email: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("empty `context`")); + assert!(err.to_string().contains("use the inferred")); + } + + #[test] + fn a_reference_plaintext_is_rejected() { + let err = parse(parse_quote! { + #[stash(plaintext = &str)] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("must be an owned type")); + } + + #[test] + fn a_repeated_plaintext_is_rejected() { + let err = parse(parse_quote! { + #[stash(plaintext = u32, plaintext = u32)] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("listed twice")); + } + + #[test] + fn a_struct_fills_in_from_and_context() { + let record = parse(parse_quote! { + #[stash(struct = crate::model::UserProfile<T>, context = "user_profiles")] + struct EncryptedUser { + age: EncryptedAge, + #[stash(from = email_address)] + email: StackCipherText, + #[stash(context = "legacy/name")] + name: StackCipherText, + #[stash(nested)] + address: EncryptedAddress, + #[stash(default)] + version: u8, + } + }) + .unwrap(); + assert!(record.by_field); + assert_eq!(record.plaintexts.len(), 1); + let (age, email, name, address, version) = ( + &record.fields[0], + &record.fields[1], + &record.fields[2], + &record.fields[3], + &record.fields[4], + ); + // Own name under the container's prefix. + assert!(matches!(age.from(), Some(Member::Named(m)) if m == "age")); + assert_eq!(own(age), "user_profiles/age"); + // `from` overrides the field; the context follows the plaintext field. + assert!(matches!(email.from(), Some(Member::Named(m)) if m == "email_address")); + assert_eq!(own(email), "user_profiles/email_address"); + // `context` is taken verbatim; like the inferred ones, the caller's + // context extends it. + assert!(matches!(name.from(), Some(Member::Named(m)) if m == "name")); + assert_eq!(own(name), "legacy/name"); + // `nested`: no inferred context — the field is handed the caller's. + assert!(matches!(address.from(), Some(Member::Named(m)) if m == "address")); + assert!(matches!(address.field_context(), FieldContext::Caller)); + assert!(!version.is_derived()); + } + + #[test] + fn a_tuple_struct_is_reached_and_named_by_index() { + let record = parse(parse_quote! { + #[stash(struct = Reading, context = "readings")] + struct EncryptedReading(EncryptedAge, StackCipherText); + }) + .unwrap(); + assert!(matches!(record.fields[1].from(), Some(Member::Unnamed(i)) if i.index == 1)); + assert_eq!(own(&record.fields[0]), "readings/0"); + assert_eq!(own(&record.fields[1]), "readings/1"); + } + + #[test] + fn a_struct_requires_a_container_context() { + // The prefix is part of the stored data's identity, so it is never + // inferred from the Rust type's name: two types named `Account` in + // different modules would otherwise silently share every field + // context. + let err = parse(parse_quote! { + #[stash(struct = User)] + struct EncryptedUser { + age: EncryptedAge, + } + }) + .unwrap_err(); + let message = err.to_string(); + assert!(message.contains("needs a `context = \"..\"`"), "{message}"); + assert!(message.contains("naming the stored data"), "{message}"); + } + + #[test] + fn a_container_context_requires_a_struct() { + let err = parse(parse_quote! { + #[stash(plaintext = User, context = "users")] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("applies only with `struct = ..`")); + + let err = parse(parse_quote! { + #[stash(context = "users")] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("applies only with `struct = ..`")); + } + + #[test] + fn an_empty_container_context_is_rejected() { + let err = parse(parse_quote! { + #[stash(struct = User, context = "")] + struct EncryptedUser { + age: EncryptedAge, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("name the stored data")); + } + + #[test] + fn nested_applies_only_with_struct_and_excludes_context() { + let err = parse(parse_quote! { + #[stash(plaintext = User)] + struct Rec { + #[stash(nested)] + user: EncryptedUser, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("applies only with `struct = ..`")); + + let err = parse(parse_quote! { + #[stash(struct = Account, context = "accounts")] + struct Rec { + #[stash(nested, context = "accounts/user")] + user: EncryptedUser, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`context` does not apply")); + } + + #[test] + fn struct_and_plaintext_are_exclusive() { + let err = parse(parse_quote! { + #[stash(struct = User, plaintext = User)] + struct Rec { + age: EncryptedAge, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("give one of them")); + } + + #[test] + fn a_struct_must_name_a_struct_directly() { + let err = parse(parse_quote! { + #[stash(struct = &User)] + struct Rec { + age: EncryptedAge, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("must name a struct directly")); + + let err = parse(parse_quote! { + #[stash(struct = <T as Trait>::Row)] + struct Rec { + age: EncryptedAge, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("must name a struct directly")); + } + + #[test] + fn context_type_replaces_the_default_caller_context() { + let record = parse(parse_quote! { + #[stash(plaintext = String, context_type = AeadContext)] + struct Rec { + c: StackCipherText, + #[stash(context = "legacy/name")] + shadow: StackCipherText, + } + }) + .unwrap(); + let ty = |ty: &Type| quote!(#ty).to_string(); + assert_eq!(ty(&record.context_type(false)), "AeadContext"); + assert_eq!(ty(&record.context_type(true)), "AeadContext"); + // Both fields are handed the caller's type: the literal one through + // its `under`, which returns the same type. + assert_eq!( + ty(&record.field_context_type(&record.fields[0])), + "AeadContext" + ); + assert_eq!( + ty(&record.field_context_type(&record.fields[1])), + "AeadContext" + ); + + let record = parse(parse_quote! { + struct Rec { + c: StackCipherText, + } + }) + .unwrap(); + assert!(record.context_type.is_none()); + assert_eq!( + ty(&record.context_type(false)), + ":: stack_encrypt :: target :: CallerContext" + ); + } + + #[test] + fn context_type_applies_only_where_the_caller_settles_the_context() { + let err = parse(parse_quote! { + #[stash(struct = User, context = "users", context_type = AeadContext)] + struct Rec { + name: StackCipherText, + } + }) + .unwrap_err(); + assert!( + err.to_string() + .contains("a `struct` derive's fields carry their own"), + "{err}" + ); + + let err = parse(parse_quote! { + #[stash(context_type = AeadContext)] + struct Rec { + #[stash(context_field)] + tenant: String, + c: StackCipherText, + } + }) + .unwrap_err(); + assert!( + err.to_string().contains("`context_type` does not apply"), + "{err}" + ); + + let err = parse(parse_quote! { + #[stash(context_type = AeadContext)] + struct Rec { + #[stash(context = "users/name")] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!( + err.to_string() + .contains("every field here carries a `context"), + "{err}" + ); + + let err = parse(parse_quote! { + #[stash(context_type = AeadContext, context_type = AeadContext)] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!( + err.to_string().contains("`context_type` is given twice"), + "{err}" + ); + } + + #[test] + fn plaintext_fields_classify() { + let record = parse(parse_quote! { + #[stash(plaintext = u32, plaintext = u64)] + struct Rec { + #[stash(context = "users/age", decrypt)] + c: StackCipherText, + hm: EqualityTerm, + #[stash(default = SchemaVersion::V3)] + v: SchemaVersion, + } + }) + .unwrap(); + assert!(!record.by_field); + assert_eq!(record.plaintexts.len(), 2); + assert_eq!(record.fields.len(), 3); + // Nothing is derived from a field of the plaintext. + assert!(record.fields.iter().all(|f| f.from().is_none())); + assert_eq!(own(&record.fields[0]), "users/age"); + assert!(record.fields[0].decrypt); + assert!(record.fields[1].is_derived()); + assert!(matches!( + record.fields[1].field_context(), + FieldContext::Caller + )); + assert!(!record.fields[2].is_derived()); + } +} diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md new file mode 100644 index 000000000..5bff1e910 --- /dev/null +++ b/packages/stack-encrypt/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Breaking + +- **Sealed leaves gained a version byte, and the leaf AAD that binds it.** + Leaves sealed before this change used an unlabelled `PAE(aad, tag)` leaf + AAD with no version byte; the derivation is now + `PAE("stack-encrypt/leaf", version, derived_aad, tag)`. Leaves sealed under + the old derivation cannot be opened by this build — however they were + persisted (`serde`, `into_parts`, or raw bytes) they fail AEAD verification + with a plain authentication error, indistinguishable from tampering, + because the old form carries no version byte to raise + `LeafBytesError::UnknownVersion` against. Acceptable only because the crate + is `publish = false` and only dev-persisted data exists; from + `SealedValue::FORMAT_VERSION` onwards a format move is signalled by the + version byte instead. + +### Added + +- `SealedValue::to_bytes` / `from_bytes` / `TryFrom<&[u8]>`: the canonical, + frozen v1 leaf encoding + (`version ‖ iv ‖ u16 tag_len ‖ tag ‖ local_ciphertext`), with + `LeafBytesError` for structural decode failures. +- Frozen transport encodings for every index term (`EqualityTerm`, + `MatchTerm`, `OreTerm`, `OpeTerm`) with `TermBytesError` for decode + failures, plus golden vectors in `tests/frozen_bytes.rs`. + +### Changed + +- `TermError`, `TermBytesError` and `LeafBytesError` are `#[non_exhaustive]`, + so the versioned decoders can gain variants without a source break. diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md new file mode 100644 index 000000000..3459d3bf6 --- /dev/null +++ b/packages/stack-encrypt/CONTEXT.md @@ -0,0 +1,224 @@ +# Stack Encrypt + +Client-side encryption of values under per-value ZeroKMS data keys, and the +derivation of searchable index terms from the same values. Covers +`stack-encrypt`, `stack-encrypt-derive`, and the WASI guest in +`bindings/go/stackencrypt/guest` that exposes them to Go. + +## Language + +**Cipher-directed**: +Encryption driven by the value's shape: the value's `Encrypt` implementation +walks the cipher and the caller decides the context, which may be absent. +_Avoid_: raw path, low-level path + +**Target-directed**: +Encryption driven by the output type: the type being produced (a ciphertext, a +term, a record) declares what it is derived from, which operations produce it, +and which context it demands; execution belongs to the cipher. +_Avoid_: typed path, high-level path + +**Operation description**: +The target's declaration of the ciphertext and term operations, source selections, +and context requirements needed to produce it. +_Avoid_: user-supplied encryption callback, caller-supplied plan (a **plan** +is the runtime form of a record's description, not a callback) + +**Plan**: +A record's operation description given as data rather than as a type, per +field: the context to bind and the outputs (`"c"`, `"eq"`, `"match"`, `"ore"`, +`"ope"`) to produce. What a binding has instead of a `struct = T` derive; +`stack_encrypt::dynamic::record` drives one. Its contexts are proven +nonempty once, when it is built, and its output keys are wire format. +_Avoid_: schema (that is the source's shape, which a plan does not describe), +mapping, config + +**Ciphertext transcoding**: +Construction or inspection of an encrypted target through its native encrypted +structure, preserving the distinctions between ciphertext, terms, metadata, and +authenticated structural markers. +_Avoid_: plaintext serialization, re-encryption + +**Context**: +The value a ciphertext is authenticated under and a term is derived under. A +leaf requires a nonempty context, validated by Vitamin C and owned in a +`CallerContext` (both encodings — what a term is derived under, and what a +record deriving terms threads to every field) or an `AeadContext` (the AAD +encoding alone — what a ciphertext is sealed and opened under; a record +deriving terms hands its ciphertext fields that half of its `CallerContext`); +a `nonempty!("users/email")` literal, a `NonEmpty::new(value)?` at runtime, +or a bare integer. It becomes the ciphertext's associated data, +the term's PRF context, and the ZeroKMS descriptor of the data key. +_Avoid_: AAD (that is one of its encodings, not the concept), lock context + +**Own context**: +The context a field carries itself: a `context = ".."` literal, or the one a +`struct = ..` derive infers as `<struct context>/<field>`. A caller's context +*extends* it (`("users/age", id)`); it is never discarded. A subtree of a +declaration is given one with `under` (the caller's is then optional) or +`extend` (the caller's stays required). +_Avoid_: default context, field prefix + +**Threaded context**: +The one context a target's declaration tree hands to every operation beneath +it (ADR-0004): a type parameter of `Encryption`, so a target cannot route what +it is handed to one operation and something else to another, and two subtrees +needing different kinds of context do not zip. `under` and `extend` are the +only ways to change it; each covers a whole subtree and is written in the +declaration, and the tree does not tell a record's two fields from a target's +two halves. +_Avoid_: scope (that is a `Pending`'s), shared context, per-operation context + +**Descriptor**: +The context, rendered as the string ZeroKMS binds into every data key and +logs per retrieval, rendered from the context's parts: plain text verbatim, +integers by their width, sign-blind (`7u64`, and `7i64` is `7u64`), a +composite's parts joined by `|` (`users/email|7u64`); text that could read as +another form is `b64:`-escaped, and an empty part inside a list is the bare +`b64:`. Injective over encodings, and finer than them for a pre-encoded +`Aad` (opaque bytes) and for shapes that encode alike (`None` vs `0u64`): +seal and open must present the context in the same shape. +_Avoid_: key name, key id + +**Leaf**: +An output type that authenticates or derives directly — a ciphertext or a +single index term — and therefore owes nothing to a context but the one it is +handed. +_Avoid_: primitive, scalar output + +**Record**: +An output type assembled from leaves derived from one plaintext (a `plaintext += T` derive); a **struct record** (a `struct = T` derive) is one whose fields +are each derived from one field of the plaintext under their own context. +_Avoid_: composite, struct (the plaintext is the struct; the record is derived from it) + +**EQL type**: +An output type that participates in EQL — a ciphertext or index term stored +for query — and so carries the contract that its context is supplied and +non-empty. EQL integration supplies a concrete identifier; generic target records need not be EQL types. +_Avoid_: searchable type, indexed type + +**Term**: +A deterministic, one-way index value derived from a plaintext under a +context — equality, match, ORE or OPE. +_Avoid_: index, token, hash + +**Pending**: +An output whose local work (term derivation, per-leaf sealing plan) is done +and whose ZeroKMS key requests are queued but not sent. Pendings compose +(`zip`, `map`, `all`) so a whole struct or `Vec` settles in one batched call. +_Avoid_: future, promise + +**Keyset**: +The ZeroKMS key domain a data key is minted under and an index key belongs +to — one per tenant is the common shape. A client may use any number; +`StackCipher` is scoped to the client, not to a keyset. Its **id** (a UUID) +is its identity: globally unique, carried in every sealed leaf, never +re-checked. +_Avoid_: dataset, key ring, tenant (a tenant *has* a keyset) + +**Keyset cipher**: +`KeysetCipher`, the cipher bound to one keyset, and what every operation +that *mints* binds to — sealing values, sealing records, deriving terms. +An owned handle (a cipher reference plus the keyset's loaded state), cheap +to clone and to hold per request. Decrypting through one is a *constraint*, +not a capability: it refuses a leaf from any other keyset. +_Avoid_: keyset handle (use "handle" only for the object, not the concept), +sub-cipher, tenant cipher + +**Scope**: +What a `Pending` was built through, and therefore what it is allowed to do: +a `KeysetCipher` scope mints under its keyset and opens leaves from no +other; a `StackCipher` scope mints nothing and opens leaves from any keyset. +`CipherScope` is the sealed trait both references implement; `dynamic::Scope` +is the same choice as a runtime value, for a binding whose caller makes it +per call. Two pendings merge when their scopes agree on a keyset — which +cipher *value* each came from is not part of the rule. +_Avoid_: binding (that is a name's), context (that is the AAD's), opener + +**Name binding**: +The cache's record that a keyset name resolved to a keyset id, and when. +A name is a *lookup ZeroKMS answers*, not an identity — ZeroKMS allows +renames — so a binding is trusted only within a window, a keyset holds at +most one at a time, and no binding outlives the id it names. +_Avoid_: alias (the struct is called `Alias`; the concept is a binding), +name cache entry + +**Freshness window**: +How long a name binding is trusted before the next selection by that name +asks ZeroKMS again (`DEFAULT_NAME_TTL`, five minutes; +`StackCipherBuilder::keyset_name_ttl`). It bounds how long a rename can go +unnoticed by a running process, the way a resolver's TTL does; `ZERO` makes +every selection by name a round trip. Selection by id has no window. +_Avoid_: cache expiry, staleness (a binding past its window is *stale*, the +window itself is not) + +**Resolution ticket**: +A monotonic stamp (`Resolution`) a lookup takes on its way to ZeroKMS and +hands back on insert. Resolutions run outside the cache lock, so answers +land in any order; the ticket is what says which *question* was later, and a +binding follows the later question rather than the earlier arrival. +_Avoid_: generation, version, sequence number + +**Watermark**: +The place in the resolution order of the latest answer the cache holds +nothing of to order an older answer against: an entry eviction has dropped, +or ZeroKMS's answer that a name is bound to nothing. Once an entry is gone +there is nothing left to order an older answer for that keyset against, and +a negative answer is held as no binding at all, so no binding is made from +an answer older than the watermark. One watermark for every name, not one +per forgotten name — a cache whose whole contract is a bound must not grow +a record per eviction or per unbound name. +_Avoid_: tombstone, negative cache, evicted binding (it is the entry's +place, not a binding's), eviction watermark (eviction is one of two things +that raise it) + +**Foreign keyset**: +A keyset other than the one a `KeysetCipher` is bound to, from that +handle's point of view. Handing it a leaf sealed under one is +`Error::ForeignKeyset`, refused before any key is retrieved — the +guarantee a tenant-scoped handler asked for by taking a handle. +_Avoid_: wrong keyset, other tenant + +## Guest memory (Go host) + +**Reservation**: +The guest's whole linear memory, address space of the module's declared +maximum taken once (`mmap PROT_NONE`, `VirtualAlloc MEM_RESERVE`) so the +memory never moves. Growth commits more of it from the front. +_Avoid_: buffer (that is wazero's view of the committed part), allocation + +**Commit**: +Making a range of the reservation readable and writable as the guest grows, +and locking it. A commit is what a lock is granted or refused on. +_Avoid_: grow (that is the guest's request; the commit is the host's answer) + +**Lock**: +Pinning committed memory in RAM (`mlock`, `VirtualLock`) so it is never +written to swap, and on Linux excluding the reservation from core dumps +(`MADV_DONTDUMP`). "Locked", of a client, means both held. +_Avoid_: pinned, wired + +**Lock policy**: +What a refused lock means for a client. *Best effort*, the default: the +refusal is recorded and reported (`MemoryLocked`, `MemoryLockError`) and +the client works on with memory that may be swapped. *Strict* +(`RequireLockedMemory`): `NewClient` fails with `ErrMemoryLock`, and so +does any later call whose growth cannot be locked. +_Avoid_: mode, hard/soft + +**Growth refusal**: +Under the strict policy, a commit whose lock was refused and was therefore +given back before the guest saw it. It fails the call that needed it and +leaves the client's lock report unchanged, since nothing unlocked was +admitted. A refusal of the guest's own allocation aborts the guest and +closes the client. +_Avoid_: lock failure (that is the report of memory admitted unlocked) + +**Heap fallback**: +A Go slice standing in for a reservation where none can be made: a +platform with no primitive this package uses, or a 32-bit host asked for +wasm's 4 GiB default. It still wipes on growth and release; it cannot be +locked, and the client reports so. +_Avoid_: default allocator (wazero's, which is never used), unlocked mode + diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml new file mode 100644 index 000000000..6a8f12365 --- /dev/null +++ b/packages/stack-encrypt/Cargo.toml @@ -0,0 +1,104 @@ +[package] +name = "stack-encrypt" +description = "Encrypt Rust values under per-value ZeroKMS data keys via the vitaminc cipher traits" +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" +# Not yet released: keep release-plz from picking this crate up (it processes +# any workspace crate whose Cargo.toml lacks `publish = false`). +publish = false + +[dependencies] +# `profile` (native only; stack-kms gates it off wasm32 itself) lets +# `StackCipher::new()` fall back to the client key `stash auth login` writes +# to the profile directory (`secretkey.json`) when CS_CLIENT_ID / +# CS_CLIENT_KEY are not set — the same place `AutoStrategy` finds the token. +stack-kms = { path = "../stack-kms", default-features = false, features = ["profile"] } +# `StackCipher::new()` builds a ZeroKMS client from the environment, so its +# return type names the auto-detected auth strategy. Only needed with `http`. +stack-auth = { workspace = true, optional = true } +# `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`, re-exported from `target`. +stack-encrypt-derive = { path = "../stack-encrypt-derive" } + +# The workspace vitaminc (0.5.0): one canonical context encoding shared by +# the AEAD and PRF derivations (`IntoContext`, cipherstash/vitaminc#339), +# `NonEmpty::with`, `From<integer> for NonEmpty` (#314) and the parts view +# (#318), all of which this crate relies on. All five must share one +# version or the aead crate is duplicated. +vitaminc-aead = { workspace = true } +# `dynamic`: the runtime value model every language binding funnels through. +vitaminc-aead-value = { workspace = true, optional = true } +vitaminc-encrypt = { workspace = true } +vitaminc-hmac = { workspace = true } +vitaminc-prf = { workspace = true } +vitaminc-protected = { workspace = true } +serde = { workspace = true } +# `Descriptor`: the base64 rendering of a non-textual context for ZeroKMS. +base64ct = { version = "1.7", features = ["alloc"] } + +cllw-ore = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +zeroize = { workspace = true } + +[features] +default = ["http"] +# `StackCipher::new()` / `StackCipher::builder().init()` from the environment: +# the default HTTP transport in stack-kms and the auto-detected auth strategy +# in stack-auth. Off, a cipher is built over an explicit `DataKeySource` +# (`StackCipher::builder().kms(..)`) and no HTTP client or TLS stack is in +# the dependency graph — the shape the WASI/wazero guest builds against. +http = ["dep:stack-auth", "stack-auth/http", "stack-kms/http"] +# `stack_encrypt::dynamic`: encrypt and decrypt values whose types are known +# only at runtime, as an FFI binding's are. Off by default — it is only of +# use to a binding author, and it pulls `vitaminc-aead-value` in. +dynamic = ["dep:vitaminc-aead-value"] + +[dev-dependencies] +serde_json = { workspace = true } +# `default-features = false` here too: dev-dependency features unify into the +# `cargo test -p stack-encrypt --no-default-features` graph, so leaving the +# default on would silently pull `stack-kms/http` -> `stack-auth/http` -> +# reqwest back into the "no HTTP" gate (`wasm:no-http-test`). The tests only +# need `FakeDataKeySource` (`test-support`). +stack-kms = { path = "../stack-kms", default-features = false, features = ["test-support"] } +# To build the request errors ZeroKMS answers a keyset lookup with (a name it +# does not know, a request that got no answer) in a test double. Already in +# the no-HTTP graph through stack-kms; it has no features of its own. +zerokms-protocol = { workspace = true } +tokio = { workspace = true, features = ["rt", "macros"] } +# Compile-fail tests for the derive diagnostics (`tests/ui`). +trybuild = "1" + +# The derive diagnostics are recorded with every feature on, as `test:unit` +# and CI run them. They cannot hold under both feature sets — rustc lists a +# trait's other implementors in its help, and the `dynamic` feature's +# `FfiValue` joins one such list and pushes another entry off it — so the +# test is only built with that feature rather than skipped at runtime. +[[test]] +name = "ui" +required-features = ["dynamic"] + +[[example]] +name = "encrypted_record" +required-features = ["http"] + +[[example]] +name = "mixed_user" +required-features = ["http"] + +[[example]] +name = "search_terms" +required-features = ["http"] + +[[example]] +name = "zerokms_auth" +required-features = ["http"] + +[package.metadata.docs.rs] +all-features = true diff --git a/packages/stack-encrypt/LICENSE b/packages/stack-encrypt/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-encrypt/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + +<https://polyformproject.org/licenses/internal-use/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-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md b/packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md new file mode 100644 index 000000000..a359d50f2 --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md @@ -0,0 +1,67 @@ +--- +status: superseded by ADR-0003 +date: 2026-09-03 +--- + +# The cipher-directed path takes any context; the target-directed path takes a `NonEmpty` + +Superseded on 2026-09-12 by +[ADR-0003](0003-declarative-targets-and-ciphertext-transcoding.md). The replacement +retains the allowance for absent context on the cipher-directed path and the +nonempty-context requirement for EQL operations, but moves target context +requirements into declarations executed by core code. The original rationale +below is retained as history; ADR-0003 records the accepted design, which +this crate's operation descriptions, native readers, and derives implement. + +`StackCipher::encrypt` / `decrypt` / `decipher` (the cipher-directed path) accept +any `IntoAad`, including `()`, exactly as vitaminc's `Aes256Cipher` does: sealing +under no associated data is a legitimate AEAD use, and the same `StackCipherText` +type opens symmetrically. The requirement that a context be **supplied and +non-empty** is a property of EQL types — ciphertexts and index terms stored for +query, where an empty context would make ciphertexts transplantable between +fields and collapse per-field term domains — so it is enforced on *their* +`EncryptFrom` / `DecryptInto` / term-generator implementations, by type (a leaf +is implemented for `vitaminc_protected::NonEmpty<T>` alone; the crate owns no +context trait of its own), and nowhere else. + +The WASI guest mirrors the split: its value exports (`se_encrypt` and friends) +are the cipher-directed path and take any AAD, a `nil` Go slice included; its +record and term exports parse their context into a `NonEmpty` and refuse an +empty one with `STATUS_ENCODING`. + +## Considered options + +- **Require `NonEmpty` on the cipher-directed path too** (remove the public + `Cipher` impl, route every seal through a `StackCipher` method that takes a + `NonEmpty`). Rejected: it forces a context on callers that are not producing + EQL types, and diverges from the vitaminc cipher contract the type is meant to + mirror. +- **Re-check emptiness on the encoded bytes** (the previous `is_degenerate_aad` + / `is_degenerate_prf_context` predicates). Rejected: an encoded context can + only be judged on its bytes, and framing makes empty composites non-empty as + bytes; vitaminc deliberately keeps `MaybeEmpty` (`IsEmpty` before 0.3.0) off `Aad` and `PrfContext` + for that reason. + +## Consequences + +- A tree sealed cipher-directed under `()` and opened target-directed under a + `NonEmpty` fails as an ordinary context mismatch — ZeroKMS refuses the key + retrieval under the other descriptor (`Error::Kms`), and a key source that + ignores descriptors lets it reach the AEAD (`Error::Aead`) — not as a + special case. The two paths are different contracts on one ciphertext type; + a front-end that seals cipher-directed but opens target-directed (the WASI + guest's record plans) binds the same `NonEmpty` value on both sides. +- `0u64` and eight zero bytes are valid contexts: vitaminc's rule is that an + integer is never empty. The byte collision the old predicate guarded + against is real — `None::<&str>` encodes as `pae([])`, eight zero bytes, + the same as `0u64`, and `None` is constructible on the cipher-directed path + and inside a `NonEmpty` tuple — but it is the AEAD's collision, not the + crate's to police: the two shapes render to different descriptors (`()` and + `0u64`), so ZeroKMS binds them to different keys and a cross-open is + refused there. See the descriptor module docs. +- The context is also the ZeroKMS descriptor of every data key + (`Descriptor::from_piece`), so a cipher-directed seal under `()` requests its + keys under the empty descriptor. That is the caller's choice, made visible + in the ZeroKMS log. +- Future architecture reviews should not re-propose "closing" the + cipher-directed path; the asymmetry is the design. diff --git a/packages/stack-encrypt/docs/adr/0002-keyset-id-in-the-leaf-and-names-as-bounded-lookups.md b/packages/stack-encrypt/docs/adr/0002-keyset-id-in-the-leaf-and-names-as-bounded-lookups.md new file mode 100644 index 000000000..cda61fae9 --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0002-keyset-id-in-the-leaf-and-names-as-bounded-lookups.md @@ -0,0 +1,111 @@ +--- +status: accepted +date: 2026-09-12 +--- + +# The keyset id goes in the v1 leaf without a format bump, and a keyset name is a bounded lookup + +CIP-4037 made `StackCipher` client-scoped — one ZeroKMS client, many keysets — +and moved everything that *mints* onto `KeysetCipher`, the cipher bound to one +of them. Two decisions in that change are worth recording, because both trade +something away and neither is obvious from the code alone. + +## 1. The v1 leaf layout gained 16 bytes in place, with no `FORMAT_VERSION` bump + +A `SealedValue` now carries the id of the keyset its data key was minted under: +16 raw UUID bytes immediately after the version byte, ahead of the `iv`. That +is what lets `StackCipher::decrypt` open leaves from any keyset — the leaf says +which keyset to retrieve from, so the caller does not have to — and what lets a +leaf lifted out of its tree, which is what a database column holds, stay +self-describing. + +The field was inserted into the v1 layout and `SealedValue::FORMAT_VERSION` +stayed at `0x01`. Normally that is exactly the change a version byte exists to +mark. Here it is safe, and a bump would have bought nothing: + +- The crate is unpublished (`publish = false`, 0.1.0) and nothing produced by + it is stored anywhere. There is no old-layout leaf in the world to read. +- An old-layout leaf could not be *silently* misread even if one existed. The + keyset id is bound into the leaf AAD alongside the version byte — + `PAE("stack-encrypt/leaf", version, keyset_id, derived_aad, tag)` — so a leaf + sealed under the old derivation fails authentication, rather than parsing + under the wrong rules and yielding plausible bytes. The same binding is what + stops a stored leaf being re-pointed at another keyset. +- The commit is already a breaking change (`!`) for other reasons: + `SealedValue::from_parts` / `into_parts` carry the keyset id first, encrypt + moved to `cipher.default_keyset()`, and `Request::retrieve_data_key` takes a + keyset id. + +So the version byte is spent once, when there is a reader to protect. The next +layout change — after the first release that stores leaves — must bump it. + +## 2. A keyset name is a lookup with a bounded freshness window, not an identity + +A keyset's **id** is its identity: globally unique, carried in every leaf, and +never re-checked once resolved. A **name** is not. ZeroKMS answers a name with +an id and allows a keyset to be renamed, so a name this process resolved +earlier can mean a different keyset later. The cache therefore treats a +name-to-id binding the way a resolver treats a DNS record. + +The rules, each of which exists because the alternative was a live defect +found in review: + +- **A binding is fresh only within a window** (`DEFAULT_NAME_TTL`, five + minutes; `StackCipherBuilder::keyset_name_ttl`; strictly `<`, so + `Duration::ZERO` is never fresh whatever the clock's resolution). After it, + the next selection by that name asks ZeroKMS again and the binding is + refreshed or moved. Within it, a rename is invisible — that is the cost, and + it is bounded. Selection by id is never re-asked. +- **One binding per keyset.** A keyset has one name at a time in ZeroKMS, so + resolving it under a new name means its old name was renamed away, and that + binding goes; a name that moves to another keyset is dropped from the keyset + it used to name. Without this a renamed hot keyset grew the name index + without bound, which is unacceptable in a structure whose whole contract is + a bound. +- **A binding follows the later *lookup*, not the earlier *arrival*.** + Resolutions run outside the cache lock, so their answers land in any order. + Every lookup that goes to ZeroKMS carries a monotonic `Resolution` ticket + from `get` to `insert`, and an answer is applied only if it is later than the + one that already spoke for that keyset or name. +- **An answer older than the one a keyset already holds is dropped whole**, not + just for the name it asked under. Comparing per name only was not enough: a + rename could be undone *across two names* — a selection by the old name + starts, the keyset is renamed, a selection by the new name starts and answers + first, and the older answer then found no binding for the old name to lose to + and rebound it, routing a name ZeroKMS may since have given to another keyset + here for a whole window. +- **Eviction leaves a watermark.** Evicting an entry drops both the keyset's + place in the order and its binding, and an answer older than what went would + then find nothing left to say it is the older one. Every eviction therefore + records the *entry's* place (not its binding's — an entry whose name has + already moved to another keyset is precisely the one an old answer would + rebind), and no binding is made from an answer older than that. It is one + watermark for all names rather than one per forgotten name, which is what + keeps the structure bounded; the price is that it also refuses some bindings + an older lookup could have made safely, costing a round trip on the next + selection by such a name — in the eviction regime that is already paying + them. +- **A negative answer raises the same watermark.** ZeroKMS answering a name + lookup with "no such keyset" is an answer about the name, held as no binding + at all: the binding an earlier lookup made goes, and the watermark rises to + that lookup so an earlier positive answer still in flight cannot bind the + name after ZeroKMS has said it is bound to nothing. Only ZeroKMS's own answer + counts; a lookup that failed to get one leaves the cache as it was. + +## Consequences + +- **Key material is never the thing at risk.** An id's index key is the same + whichever lookup asked for it, so every rule above governs *names only*; an + answer too old to order still caches its keyset by id. Nothing stored depends + on the cache at all — a leaf carries its keyset id and a term carries nothing + — so eviction is invisible except for the round trip the next lookup pays. +- **A rename is visible within the window, never sooner.** Callers that cannot + tolerate that select by id, or set `keyset_name_ttl(Duration::ZERO)` and pay + a round trip per selection. +- **The default keyset is not a special case in any of this.** Its state never + changes and it never evicts, but its builder-time name ages, moves and + reorders exactly like any other keyset's, and the cache reaches it through + the same accessors. +- **These rules are the cache's, not ZeroKMS's.** ZeroKMS remains the authority + on what a name means and on whether the client may use the keyset at all; the + window only bounds how long this process trusts an answer it already has. diff --git a/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md b/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md new file mode 100644 index 000000000..b27ccc3ed --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md @@ -0,0 +1,126 @@ +--- +status: accepted +date: 2026-09-12 +supersedes: ADR-0001 +--- + +# Declare target operations and transcode native encryption output into records + +The current target-directed extension gives each output implementation the +plaintext and cipher, allowing it to replace the plaintext's Vitamin C encoding; +the initial EQL integration demonstrated this by serializing plaintext with +MessagePack and encrypting the resulting bytes. Targets will instead declare +their operations and context requirements, with Stack Encrypt executing the +operations and constructing the target through a visitor over native encryption +output. This preserves Vitamin C's plaintext contract while supporting derived +records without an additional serialized buffer or generic intermediate tree. + +This records the accepted architecture, implemented by the core operation +descriptions, native readers, and derives in this crate. It supersedes +[ADR-0001](0001-context-optional-cipher-directed-path.md) as the current context +and target-extension contract, carrying forward the context policies stated +below. EQL-shaped integration tests exercise the consumer contract; wiring the +actual EQL crate and extending Vitamin C plaintext coverage remain separate work. + +## Decision + +`EncryptFrom<P>` remains the declaration that an encrypted target can be produced +from plaintext `P`. Its derive supplies an associated `Context` type, an operation +description assembled from core-supported operations, and a visitor that builds +the target from their results. It has no overridable method receiving both the +plaintext and cipher. The cipher executes the declaration: its ciphertext +operation calls Vitamin C's `Encrypt`, and its term operations use the respective +PRF or ordering capabilities. A target that only produces terms requires those +capabilities without unnecessarily requiring recoverable encryption. + +The output type determines the operations. Semantic field types and explicit +derive configuration identify ciphertext, terms, defaults, and context metadata; +untyped bytes or field names alone cannot identify an operation. Settings such as +normalization and tokenization must be declared where the types do not determine +them. Callers provide plaintext and the target's context, not a separate plan: + +```rust +// Consumer call-site shape; Identifier and TextEq belong to EQL. +let identifier = Identifier::for_column("users", "email")?; +let encrypted: TextEq = keyset.encrypt_as(&email, identifier).await?; +``` + +For EQL, the encryption context is `NonEmpty<Identifier>`, with the underlying +identifier stored in `i`. Its table and column components supply the context for +the ciphertext, terms, and ZeroKMS descriptor. EQL does not infer context from +Rust struct/field names or duplicate the identifier in literal attributes. The +storage envelope's `c`, `hm`, and other field names do not add plaintext map-entry +context derivations. Plaintext maps continue to use Vitamin C's own derivations. + +A target can require no caller context (`Context = ()`) when its declaration +already supplies the contexts its operations require. EQL ciphertext and term +operations still require nonempty context. Context encoding and emptiness proofs +remain Vitamin C's responsibility. The cipher-directed API continues to accept +any supported context, including `()`, and remains available; this decision does +not close it. A concrete associated context type does not implicitly accept +arbitrary context extensions; any extension facility must preserve the declared +base context and be specified explicitly. + +Transcoding consumes native encryption output through a custom encrypted-data +protocol. It distinguishes sealed leaves, sequences, maps, authenticated absence +and empty-container markers, passthrough metadata, and typed terms. Readers expose +existing outputs directly to target visitors; they do not first construct another +universal value tree. The existing pending cipher structure, batching state, native +ciphertext output, and final target allocations remain legitimate. This is not a +promise of zero allocation or of eliminating the cipher's own structures. + +`DecryptInto<P>` declares how to inspect the encrypted target, select recoverable +ciphertext, and obtain its context so core code can invoke Vitamin C's `Decrypt`. +For EQL, stored `i` is validated before key retrieval; an externally supplied +expected identifier is checked against it when destination validation is wanted. +Terms do not recover plaintext, and query-only targets have no `DecryptInto`. +Construction and inspection readers are supporting protocols, not additional +public `FromEncrypted` / `IntoEncrypted` derives. EQL encrypted payloads do not +implement the plaintext-side `Encrypt` / `Decrypt` traits. + +## Considered options + +- **Keep arbitrary target encryption methods, with documentation or an added + `P: Encrypt` bound.** A bound cannot require a method body to call that + implementation. A default method remains overridable. Neither prevents the + EQL plaintext-serialization bypass. +- **Use a core-owned encoded-ciphertext wrapper with a format adapter.** This + protects the covered leaf conversion and can also avoid intermediate formats, + but does not supply a shared structural protocol for records, collections, + metadata, and terms. Its responsibility separation informs the chosen design. +- **Use Serde as the transcoding protocol.** The `async-sync` spike demonstrates + direct visitor-driven construction from a cipher's normal output without a + serialization round trip. We adopt that pattern with an encrypted-data model: + byte strings and ordinary null/empty values do not express the distinctions + needed to preserve sealed leaves and authenticated structural markers. + +## Consequences + +- The target traits, derives, container composition, and affected bindings need + coordinated changes. The existing API is unpublished, but local call sites + still need migration. Supported operation descriptions must not admit arbitrary + plaintext-and-cipher callbacks that recreate the bypass. The guarantee concerns + target-directed execution, not all code a caller could write with a cipher. + A field selector (`Encryption::project`) is a capture-free function pointer + over a borrow: it cannot reach the cipher, but nothing stops it returning + bytes it made up. The guarantee is that no target code holds plaintext and + cipher together, not that a selector's output is the plaintext it was given. +- The implementation must preserve batching and keyset scope, move outputs through + consuming readers, and retain authenticated markers and cryptographic map keys. + Unsupported shapes must fail explicitly. Stored context is not inherently + trusted merely because it was parsed, and metadata/terms must not be presented + as AEAD-authenticated values by the transcoder. +- Missing plaintext `Encrypt` / `Decrypt` capabilities belong in Vitamin C, with + encoding, precision, and domain behavior specified there. EQL must not restore + a Serde fallback or substitute tagged FFI wrappers for direct Rust plaintext + implementations without an explicit representation decision. +- Verification must include cross-opening ciphertext between the canonical and + target-directed paths, plaintext types without Serde implementations, required + context checks, marker and shape handling, query-only behavior, and batching. + A round trip confined to one adapter is insufficient evidence of compatibility. +- This decision does not establish interoperability with existing + `cipherstash-client` EQL producers or change persisted formats. JSON/SteVec's + shared document key and selector semantics need their own supported operations; + a scalar transcoder does not settle that design. Operation-description and + reader signatures, generic-target context ergonomics, and those document + operations must be validated before claiming complete EQL coverage. diff --git a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md new file mode 100644 index 000000000..74cf00f66 --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md @@ -0,0 +1,309 @@ +--- +status: accepted +date: 2026-09-13 +extends: ADR-0003 +--- + +# One context per target, threaded through the declaration tree + +[ADR-0003](0003-declarative-targets-and-ciphertext-transcoding.md) gave targets +an associated `Context` type and a declaration they compose from core +operations. That settles what a caller must *supply*. It does not settle where +the supplied value *goes*, and the gap is load-bearing: a target that produces +a ciphertext and its index terms can hand each operation a different context, +and nothing — not the type system, not a runtime check, not a test — relates +them. + +This ADR records how a context reaches the operations beneath it, and why the +answer is a type parameter rather than a convention. + +## The problem + +Every operation constructor takes its own context: + +```rust +ciphertext::<S, K>(ctx_a).zip(equality::<S, K>(ctx_b)) // compiles +``` + +`Self::Context` is plumbing the implementation may route, reroute or discard. +`zip` combines builders and relates nothing. So a composite target can seal a +value under one context and index it under another. + +The two mistakes fail differently, and that asymmetry is why this matters more +than it first appears: + +| mistake | how it surfaces | +| --- | --- | +| ciphertext under the wrong context | ZeroKMS refuses the retrieve (its descriptor is HMAC'd into the key tag), or the AEAD rejects — loud, at first read | +| term under the wrong context | a valid term in a different domain. A probe built correctly never equals it | + +A mis-contexted term produces no error, ever. Queries return nothing, and an +empty result is indistinguishable from no matching rows. The symptom appears in +the read path, possibly long after the write, and looks like missing data rather +than a fault. + +Derived records are safe today because `#[derive(EncryptFrom)]` passes one +`context_expr` per field to that field's operations. The exposure is +hand-written *composite* targets — which EQL has (the SteVec/JSON ones), and +which any external consumer writes with no derive to save them. + +Per-field contexts differing is **not** the problem; that is deliberate domain +separation. The problem is that within one field, the ciphertext and its terms +have no relation. + +## Decision + +### 1. Operations take no context; the tree carries one + +`Encryption::build` gains the context as a parameter, and `zip` hands the same +value to both sides. A target cannot *route* the context it is handed — there +is no argument to forget, swap, or fill from the wrong variable, because there +is no argument. + +```rust +ciphertext::<S, K>().accepting().zip(equality::<S, K>()) // one value reaches both +``` + +(`accepting` converts the ciphertext's context *type*, as decision 3 explains; +the value passes through.) + +What this does not rule out is a target that gives each half a context of its +own, by name: + +```rust +ciphertext().under(nonempty!("cipher")).zip(equality().under(nonempty!("term"))) +``` + +That compiles, and the term is under a different context from the ciphertext. +It is the same construct, to the letter, as a record giving each of two +*fields* its own context (decision 2), and the tree cannot tell a two-context +target from a two-field record: the reason the runtime check in `zip` is +rejected below applies to the type system too. What changes is what the +divergence costs to write. It is two literals in the declaration, each naming +the context it sets, where before it was one supplied value reaching one side +and something else reaching the other — visible at review, where a routing +mistake was not. + +### 2. `under` and `extend` are the only ways to change it, and each covers a whole subtree + +A record gives its fields different contexts once each, visibly, instead of +threading six arguments: + +```rust +age.under(nonempty!("users/age")).zip(email.under(nonempty!("users/email"))) +``` + +`under` gives a subtree a context of its own, which a caller's context +extends if one is given — so the result can run under `()`. `extend` does the +same for a record that cannot make the caller's context optional, because +some *other* field of it is a bare leaf: the subtree's own context is +extended by the caller's, which stays required. `under` is available wherever +a `CallerContext` can become what the subtree needs, and `extend` wherever +the caller's context — a `CallerContext`, or an `AeadContext` for a record +that only seals — can; so a subtree may itself be a record whose own contexts +a caller's extends. An own context is a `NonEmpty<&'static str>`, so an empty +one is refused at compile time rather than at the first encryption. + +Two further combinators change nothing about *which* context reaches a +subtree, only its type at the root. `accepting` converts the context a record +declares its caller supplies into the one its operations need, once, at the +root — a record storing its own context declares the `NonEmpty<T>` it stores +while its operations want a `CallerContext`. `map_with_context` hands the +output the context the tree ran under, which is how such a record fills the +stored field: under threading the context arrives when the description runs, +not when it is built. + +### 3. The context is a type parameter, so the empty-context rule stays compile-time + +`Encryption<'s, S, T, K, Ctx>`, where `zip` requires both sides to share `Ctx`: + +- `ciphertext()` is `Encryption<.., AeadContext>` and `equality()` is + `Encryption<.., CallerContext>` — each needs a real context +- `.under(nonempty!("users/age"))` yields `Encryption<.., DeclaredContext>` — + now runnable under `()` or a caller's context +- zipping a bare leaf with own-context fields is a type error, which is correct + +A ciphertext and a term need different kinds of context. Sealing uses only +the AEAD encoding, so `ciphertext()` is `Encryption<.., AeadContext>`, and a +record made only of ciphertexts may declare `context_type = AeadContext` and +accept an `IntoAad`-only type, exactly as the leaf does. Deriving a term uses +the PRF encoding as well, so a term needs a `CallerContext`. The two still +zip under one value: `accepting` lets the ciphertext take the term's +`CallerContext`, of which its own `AeadContext` is the AEAD half, and that +conversion is the only thing that happens to the context between the root +and the leaf. Nothing is narrowed — a ciphertext alone seals under exactly +what it did before this ADR. + +The derive follows the same rule rather than its own. A field with a context +of its own gives its subtree that literal (`under`, or `extend` when the +record cannot make the caller's context optional). A field with none is +handed the record's context as it is, converted into whatever its type +declares it needs — unchanged for a leaf, composed with its own contexts by a +nested record, and, for a leaf reached through a record that may run under +`()`, refused at the field. The impl names concrete context types in its +bounds rather than adding a parameter: a parameter constrained only by an +associated-type binding is E0207, and the author is told "unconstrained type +parameter" instead of their mistake. + +This is the part that cost a spike to find. Threading a single *runtime* value +(`Option<CallerContext>`) is simpler and wrong: `T::Context` is deliberately +heterogeneous — `CallerContext` for a leaf, `DeclaredContext` for a record whose +fields carry their own, `ExpectedContext<T>` for a `context_field` record — so +collapsing them makes a leaf reached without a context fail at *runtime*. Today +that is a compile error, with a `#[diagnostic::on_unimplemented]` message and UI +fixtures pinning it. Trading a compile-time guarantee to buy this one is not a +trade worth making, and the type parameter buys both. + +### 4. A data-key request takes a context, not a descriptor + +`Request::generate_data_key` and `retrieve_data_key` currently accept a built +`Descriptor`. Together with `Pending::request` and `SealedValue::from_parts` — +all public, all documented as the third-party SEM extension point — that lets a +downstream implementation mint under one context and authenticate under another +with no crate code in the path. + +They take a context and render the descriptor themselves. The context is +anything `Into<AeadContext>`: a descriptor is rendered from the AEAD encoding +alone, and a ciphertext needs no more than that to seal, so the `IntoAad`-only +type a `StackCipherText` seals under can request the key it seals with. The +extension point stays; what goes is a *request* naming a descriptor that +disagrees with the context its data key is asked for under. + +What does not go: `SealedValue::from_parts` still takes raw parts, so a +downstream SEM that seals its AEAD under one AAD and requests its key under +another remains expressible. That seam *is* the extension point, and closing +it is the product decision "seal the low-level request API" declines below. +This decision narrows the exposure to a downstream assembling a sealed value +by hand; it does not remove it. + +### 5. Stored terms go through targets; standalone derivation is the query path + +`KeysetCipher::{equality_term, match_terms, ore_term, ope_term}` remain: a query +probe has no ciphertext to agree with, so constraining it means nothing. They +are documented as the query-probe path, and a term that will be *stored* is +directed to a target, where it shares its ciphertext's context by +construction. + +No code moves and nothing enforces this. It is the "convention and +documentation" option below, applied to the one place the type system cannot +reach: a probe and a stored term are the same bytes, and the term methods +cannot tell which they are producing. + +### 6. Targets declare their sources; `FfiValue` is not one + +A target names the plaintext it accepts (`TextEq` from `Protected<String>`), and +never an enum spanning every scalar. `EncryptFrom<FfiValue>` would make a +`UInt32`-into-`TextEq` a runtime error inside the crypto layer and leave every +valid pair unprovable. + +The dynamic-to-static bridge is a dispatch at the FFI boundary: a match on the +value's variant against the plan's requested target. A plan and a value that +disagree fail there, as plan validation, with the offending field named — and +the whole plan is validated before any key is requested, so a fifty-field row +does not issue thirty requests before failing on the thirty-first. + +### 7. Heterogeneous targets unify after `map`, not before + +Targets differ per field, so there is no common output type. `map` applies to an +*unsettled* `Pending` and preserves its requests, so a binding maps each target +to its own node type and collects the results — one batch, strongly typed +targets right up to the point they become wire bytes. + +A plan-driven caller collects those mapped `Pending`s with `Pending::all`, and +that is the right layer for it: a binding bridging a dynamic wire format to +static types has to hold per-field encryptions before combining them, and +naming the carrier costs nothing. What matters is the narrower property — +`encrypt_as(&source, context)` takes one context and feeds both halves — which +holds whether or not `Pending` appears in the binding's imports. + +An `Encryption::all` was proposed here and implemented, then removed: a +declaration is produced by `encryption()`, which sees no source, so the length +of such a list is fixed per *type* and cannot come from a plan. The +homogeneous runtime-length case is already `EncryptFrom<Vec<S>> for Vec<T>`, +whose length comes from the source. A combinator that composed from the source +would serve the remaining case, and is not proposed until something needs it. + +### 8. `ExpectedContext` stays permissive, deliberately + +`#[stash(context_field)]` lets a record carry its context, and the default +`ExpectedContext` accepts whatever the record stores rather than checking it +against an expected value. + +This is a decision, not an oversight, and the reason is the onboarding flow: add +`email_encrypted`, migrate, drop `email`, rename `email_encrypted` to `email`. +Every historical row still stores the pre-rename identifier, so a strict check +would reject all of them at the first read after a rename. + +The consequence is acknowledged: an identifier that must survive renames cannot +also enforce placement. What it provides is a label, not a guarantee. A whole +self-consistent record moved from one column to another still opens — a confused +deputy, mitigated by client-side checking rather than by this mechanism. The +AEAD and descriptor bindings are unaffected: nobody reaches a key they are not +entitled to. Dropping the stored identifier entirely is the likelier end state +than tightening the check. + +## Relation to vitaminc#341 + +That PR collapses `Aad`, `AadPiece` and `PrfContext` into one `Context` with +`IntoContext` as the only implementable trait, so a context's AAD and PRF +encodings agree by construction rather than by a test. It is the same +principle one layer down: #341 unifies how a context is *encoded*, this ADR +unifies how it is *routed*. + +Two things here get simpler when it lands. The `accepting` step between a +ciphertext and the terms beside it — an `AeadContext` taken from a +`CallerContext` — disappears, because one `IntoContext` impl gives both +encodings and the two context types collapse into one. And `CallerContext`, +which exists to hold the two encodings of one value and keep them in +agreement, thins to a newtype over `Context` or goes entirely. + +## Considered options + +**Convention and documentation.** State the invariant on `EncryptFrom` and pin +the correct pattern with an example. Kept as the fallback, and worth doing +regardless — an external consumer reads that before writing a composite — but it +is enforcement by hope. + +**Detect a mismatch at runtime in `zip`.** Rejected: `zip` cannot distinguish a +record legitimately combining differently-contexted *fields* from a target +illegitimately combining differently-contexted *operations*. It would reject +valid code or miss the bug. The type parameter has the same blind spot +(decision 1); what it adds is the two compile-time rules, not the distinction. + +**Reserve `under` and `extend` for the derive**, so a hand-written declaration +could name a context only once, at its root. Not taken: a hand-written record +is a supported shape — the derive emits what one would write — and the derive +would need a private door into the same combinators. Open, if the residual in +decision 1 turns out to matter in practice. + +**Thread one runtime context.** Rejected for the reason in decision 3: it costs +the compile-time empty-context guarantee. + +**Seal the low-level request API.** Rejected: removing a documented extension +point is a product decision, and decision 4 closes the seam without it. + +## Consequences + +The routing of a context becomes structural rather than documented, and the +empty-context rule strengthens rather than weakens: `()` at a leaf is a type +error, and so is a target that discharges the context on one side of a `zip` +and leaves the other still needing it. The UI fixtures pin both: +`leaf_without_context.rs` and `nested_leaf_without_context.rs` the first, +`divergent_context_in_target.rs` the second. Two own contexts inside one target +remain expressible, as decision 1 says, because they are two fields as far as +the tree can tell. + +It costs a type parameter through `Encryption`, every operation constructor, +every combinator, `EncryptFrom::Context`, and the derive's codegen. The UI +fixtures are sensitive to far less than this; budget for them, and treat a +worsened diagnostic as a defect rather than fixture noise. + +It lands inside ADR-0003's implementation rather than after it. Once that merges +these are public signatures, and EQL builds roughly ninety-five (source, target) +pairs on them immediately — so the same change afterwards is a breaking one with +a real downstream. + +What it does **not** fix: a term and a ciphertext written through two separate +top-level calls still have no relation to each other, because neither knows the +other exists. Decision 5 narrows this to callers who deliberately bypass the +target layer on the write side. diff --git a/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md new file mode 100644 index 000000000..7cd2301fe --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md @@ -0,0 +1,250 @@ +--- +status: accepted +date: 2026-09-20 +--- + +# A separate credential guest for the profile and auth crates + +The Go binding reaches Rust through one WASI module, the crypto guest, run by +wazero with `CGO_ENABLED=0`. That guest has no filesystem and no environment: +its module config grants a name, a random source and two clocks, and its only +routes out are the two host imports the Go side owns — an HTTP transport and a +token source. The napi pattern the Node bindings use (a cdylib per crate) does +not carry over, because a cdylib needs cgo. + +Go therefore has neither `stack-profile` nor `stack-auth`. Anything that needs +the developer profile re-derives its on-disk layout by hand, and the only +`TokenSource` implementations are escape hatches. This ADR records where those +two crates run for Go, and why it is a second module rather than the first one +widened. + +## The problem + +CIP-4053 lays out three mechanisms. Extend the crypto guest and mount the +profile directory into it. Build a second, separate module for the credential +crates. Reimplement both in Go and pin the on-disk layout with a conformance +test. + +Two constraints decide it, and they pull in different directions. The crypto +guest's sandbox is a clean property today: a bug or compromise inside it +cannot read credentials off disk, because it cannot read anything off disk. +Mounting the profile into that module trades the property away, and it is the +module that handles plaintext and data keys. Against that, `stack-auth`'s +refresh path guards the token exchange with a cross-process file lock and +re-reads the token after acquiring it, because the identity provider rotates +refresh tokens and detects replay: two processes that both post the same +refresh token get the whole chain revoked. A Go reimplementation that got that +wrong would break a developer's login from a second terminal in a way that +looks like a server fault. + +A spike (2026-09-20) ran the real `stack-profile` crate as a wasip1 module +under wazero with a temporary profile mounted, and settled the facts the +decision rests on: + +- The crate compiles for wasm32-wasip1 with two gates. The `gethostname` + dependency has no wasip1 body, and the atomic write embeds + `std::process::id()` in its temp filename, which aborts the module rather + than returning an error. Nothing else changed. `stack-auth` with default + features off already builds, since the crypto guest depends on it. +- Every operation the napi binding exposes worked through the mount: current + workspace, listing, typed loads, atomic rewrite, directory creation. A read + outside the mount was refused. `CS_CONFIG_PATH` reached the guest through + wazero's environment config; the home directory did not exist on wasi. +- Every file the guest created came out mode 0600 and every directory 0700, + under a host umask of 022. The crate's own mode handling is `cfg(unix)` and + was skipped; the mode comes from wazero, which passes 0600 on every create. +- `File::lock` returned "operation not supported on this platform" cleanly. + WASI preview 1 has no file locking at all. +- Outside tests, `reqwest` appears in six call sites of one shape: post a form + or JSON body, read status and body back, plus two error conversions. That + is the shape of the crypto guest's existing `transport_send` import. + +## Decision + +### 1. One credential guest, with one mount and no environment + +`stack-profile` and `stack-auth` compile into a second WASI module, the +credential guest, embedded in its own Go package. wazero mounts exactly one +directory into it, the profile root, at a fixed guest path. The guest is given +no environment: the Go side resolves `CS_CONFIG_PATH` and the home directory +the way `ProfileStore::resolve` does, mounts the result, and constructs the +store with the guest path explicitly. The crypto guest is unchanged. + +The two modules have different blast radii, and the split is what makes that +true. The credential guest can reach one directory of credentials and, once +the auth half lands, HTTP. The crypto guest can reach neither. + +### 2. The lock stays on the host, around the whole refresh call + +WASI preview 1 cannot lock a file, so no wasm-hosted refresher can hold the +lock itself. But the discipline the refresher needs is "acquire, re-read from +disk, exchange, write, release", and that is satisfied if the Go side takes +the lock and only then calls into the guest: the guest reads the profile at +call time, after acquisition, by construction. + +So the guest has no lock calls at all. Go takes the same lock the Rust CLI +takes — `flock(LOCK_EX)` on Unix, `LockFileEx` with the exclusive flag over +offset zero and a length of all ones on Windows, on the sibling lock file the +guest names — around the device-session refresh export, and nowhere else. +Today the refresher's wasm32 arm only compiles the lock out and says nothing +about who holds it; the auth half (CIP-4054) rewrites that arm to document +that the host does, when the strategies move into the guest. No lock state +crosses the ABI, no guest code path can forget to release, and the import +surface stays filesystem plus transport. Go never spells a profile path: the lock +file's path comes from a `stack-profile` accessor exposed through the guest. + +Go acquires with a try-lock and backoff under the caller's context, where the +Rust CLI blocks. Mutual exclusion is identical; only who gives up first +differs, and a library that blocks a Go application indefinitely on a wedged +lock holder is a wedged process. + +### 3. Nothing the guest cannot do is faked + +The process id and the hostname are compiled out on wasm32, not stubbed. In +particular the creating half of `DeviceIdentity::load_or_create` is +native-only: its only production caller is the client-provisioning step at +login, which is CLI territory, and the refresh path takes the device instance +id from a claim in the token it is refreshing, not from the file. A guest that +could create an identity named after a fake hostname would be worse than one +that cannot create one. The read-only `load` stays. + +The 0600 mode on created files is wazero's default, not the crate's code. It +is the right outcome, but it holds by a property of the runtime, so the Go +side pins it with a test alongside the outside-mount refusal, the same way the +crypto guest pins its import surface. + +### 4. One Go module, one package, shared plumbing behind `internal` + +The Go module moves up to `bindings/go`, so it contains `stackencrypt`, +`stackauth` and an internal package both import. `stackauth` is one package +over the one guest, with `ProfileStore` and the Rust type names inside it; +nobody uses the profile without auth, and two packages over one embedded guest +would be two packages that must agree on one instance. Neither public package +imports the other. + +The internal package holds the locked, non-dumpable guest memory from +CIP-4111, which the credential guest gets from day one since it holds the +client key and tokens; the decoder for the status table; and the opaque +`ClientKey` type. Both public packages expose that type as an alias, so +`stackauth.ClientKey` and `stackencrypt.ClientKey` are one type by identity +without either package depending on the other, and a binary that only wants +the profile does not carry the crypto guest. + +On the Rust side the guest ABI plumbing — allocator, buffer registry, status +table, the `cipherstash_transport` host import — is extracted into a +workspace crate, `stack-guest-abi`, `publish = false`, before the second guest +is written. Two consumers is the trigger CIP-3997 was waiting for, and copying +would mean making the memory-hygiene fixes twice. The status table is one +numbering for both guests: existing numbers keep their values, and the +credential guest's profile codes append. + +### 5. Secrets: the client key is opaque and wiped; tokens stay strings + +`Config.ClientKey` becomes the opaque type rather than a string. A string is +immutable and unwipeable, and a byte slice prints its contents under `%v`. +The opaque type has a redacted `String` and `GoString`, is handed out by +`stackauth`'s typed read, and is consumed and wiped by `stackencrypt` once it +has marshalled the config. Nothing has shipped, so this is a change, not a +breaking one. + +Bearer tokens stay strings, and `TokenSource` keeps its name and shape. The +name is the `golang.org/x/oauth2` idiom every Go developer already knows, and +its ecosystem is strings end to end; a token is hours-lived and already +crosses TLS as text, where the client key is key material that lives for the +process. A one-function adapter from an x/oauth2 token source ships with the +package. + +Until refresh lands, the profile-backed `TokenSource` re-reads the auth file +on every call, so a login or refresh by the CLI in another terminal is picked +up without a restart, and it refuses a token at its real expiry timestamp +with an error that names `stash auth login`. The 90-second refresh-ahead +margin belongs to refreshing and applies there once it exists. + +### 6. The profile half ships first; the transport seam gates the auth half + +Sequence: the `stack-profile` wasm32 gates and lock-path accessor; the shared +ABI crate; the module move and internal package, stacked on CIP-4111; the +credential guest and `stackauth` with the full napi profile surface. That +closes CIP-4053 and unblocks the env-plus-profile part of CIP-4052. + +Then `stack-auth` gains a transport trait mirroring the host import exactly — +method, URL, headers and body in, status, headers and body out, bytes, no +streaming — with reqwest as one implementation behind the `http` feature and +the guest's import as the other (CIP-4116). The trait returns `impl Future`, +the crate's convention for async traits, so it is not object-safe; a +crate-internal adapter boxes the future once at construction, so no public +strategy type grows a type parameter and the concrete transport is never +named again after the builder's `.transport(..)`. Then the strategies run +inside the guest: access key, device +session, OIDC federation with a Go callback for the identity-provider token, +and auto. `AutoStrategy`'s detection order runs in Go against the environment +Go already owns, pinned against the Rust order by a test; the guest stays +environment-free. The Go package exposes typed constructors; one tagged +config crosses the guest ABI and Rust validates its variant before creating +the corresponding strategy. This keeps the public API typed without adding +separate pointer and length signatures for each strategy export. + +The token exchanges are tested against an in-process `httptest` server, +since HTTP goes through the Go host: exact request bodies, the error +taxonomy, and two goroutines racing a refresh under the lock. + +### 7. Three platforms, one wasm build + +The lock has Unix and Windows implementations from day one, since the CLI's +own lock works on both and a developer sharing a profile with the CLI is the +replay scenario the lock exists for. CI builds both guests once on Linux and +hands the artifacts to macOS and Windows runners that run both packages' Go +suites. + +## Considered options + +**Extend the crypto guest.** Rejected. It mounts a directory of credentials +into the module that handles plaintext and data keys, trading away a sandbox +property that is currently clean, to save one module. + +**Reimplement in Go.** Rejected. The lock has to be Go's regardless, so what +a Rust guest buys is one implementation of the on-disk layout, the token +wire protocol, the error taxonomy and expiry parsing. The layout has moved +once already, and the expiry bugs in CIP-3233 and CIP-3238 are exactly the +drift a Go copy would reintroduce. + +**Lock as a host import.** Rejected. The guest would call acquire and release +from inside `refresh`, so lock state crosses the ABI and a guest path can +forget to release. Wrapping the export gives the same ordering with no new +import. + +**Environment into the guest, by allowlist.** Rejected. Credential resolution +is Go's under CIP-4052 anyway, and a guest with zero environment and one +mount is easier to reason about and to pin than one with a list. + +**Two Go packages mirroring the two crates.** Rejected, as decision 4 says. + +## Consequences + +Two modules to build, embed and assert the import surface of. The memory +hygiene work covers both. `stack-profile` gains two wasm32 gates, joins the +`wasi-check` task, and widens its API by a lock-path accessor. `stack-auth` +gains a transport trait, which is the largest piece of work here and the one +CIP-3553 already anticipated. The Go module path and layout change before +anything ships. The CI matrix grows by two operating systems. + +What it does **not** fix: the client key still passes through Go host memory +between the two guests, since two wasm instances cannot share memory. It is +wiped there, not absent. And a token is a Go string on the host side, which +cannot be wiped; that is accepted for a credential that lives for hours. + +## Amendment (2026-09-27, CIP-4052): stackencrypt imports stackauth + +Decision 4 said neither public package imports the other. Credential +resolution changes one direction of that. `stackencrypt.AutoCredentials`, +the default credentials for `NewClient`, reads the profile and runs the +token strategies through `stackauth`, so `stackencrypt` imports it. The +alternative was to leave the composition to every application, which is +the gap CIP-4052 exists to close. + +The reason decision 4 gave still holds: `stackauth` does not import +`stackencrypt`, so a binary that only wants the profile does not carry the +crypto guest. A binary that encrypts now carries both guests. Neither +sandbox changes. The crypto guest still has no environment and no +filesystem, and the credential guest still has one mount. With no profile +directory, `stackauth.OpenWithoutProfile` gives it none. diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs new file mode 100644 index 000000000..c924c457c --- /dev/null +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -0,0 +1,202 @@ +//! A searchable encrypted struct, end to end. +//! +//! The point of target-directed encryption: define the encrypted *shape* of +//! a value — "the ciphertext plus the index terms this field needs" — derive +//! `EncryptFrom` / `DecryptInto` for it, and every insert is one +//! `encrypt_into(..).await`. A tiny in-memory "table" then answers equality +//! and range queries purely by comparing terms, decrypting only the rows +//! that match. +//! +//! The async shape is the other half of the point: nothing here does I/O +//! until the `.await`. Terms derive locally; each ciphertext queues its +//! data-key request; the derive combines the field pendings with `zip` / +//! `map` — so a whole `Vec` of structs, encrypted through the `Vec` +//! implementation, settles in **one** batched ZeroKMS call, and the matching +//! rows decrypt in one more. +//! +//! Run with: +//! +//! ```sh +//! cargo run -p stack-encrypt --example encrypted_record +//! ``` +//! +//! Talks to real ZeroKMS. On a developer machine, `npx stash auth login` is +//! sufficient: the cipher finds both the access token and the client key in +//! the CLI's profile directory. In CI, set `CS_CLIENT_ACCESS_KEY` / +//! `CS_WORKSPACE_CRN` and `CS_CLIENT_ID` / `CS_CLIENT_KEY` instead (see the +//! `zerokms_auth` example for the lookup order). + +use stack_encrypt::sem::{EqualityTerm, OreTerm}; +use stack_encrypt::target::{DecryptFrom, EncryptInto}; +use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, Error, StackCipher, StackCipherText}; + +// --- The shapes ---------------------------------------------------------------- + +/// "An encrypted `u32`, stored as its ciphertext plus an equality term and an +/// ORE term." The same shape as an EQL `integer_ord_ore` payload, minus the +/// EQL wire encoding. Every field is derived from the one `u32`, under one +/// context: the context authenticates the ciphertext (AAD) and +/// domain-separates both terms (PRF context). `DecryptInto` opens the +/// ciphertext field and passes over the terms — the field types say which is +/// which, so no attribute is needed. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct EncryptedAge { + c: StackCipherText, + eq: EqualityTerm, + ord: OreTerm<u32>, +} + +/// The plaintext. +#[derive(Debug, Clone, PartialEq)] +struct User { + age: u32, + email: String, +} + +/// `User`, encrypted field by field: `age` from `user.age` under +/// `"users/age"`, `email` from `user.email` under `"users/email"`. The prefix +/// is named once, explicitly — it is part of the stored data's identity, so +/// it is never inferred from a Rust type name — and the field half follows +/// the plaintext field. +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + age: EncryptedAge, + email: StackCipherText, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box<dyn std::error::Error>> { + // One cipher does everything the shapes need: ZeroKMS-backed AEAD (every + // leaf sealed under its own data key) and SEM term derivation under the + // keyset's index key, which `init` loads. Data keys and terms are bound to + // the same keyset by construction — there is no way to mix them up. + let cipher = StackCipher::new().await?; + // Sealing and term derivation bind to a keyset; this is the client's + // default one. + let keyset = cipher.default_keyset(); + + // --- Write side: encrypt a table of users --------------------------------- + + let users: Vec<User> = [ + (29, "ada"), + (34, "grace"), + (41, "edsger"), + (34, "barbara"), + (57, "tony"), + ] + .into_iter() + .map(|(age, name)| User { + age, + email: format!("{name}@example.com"), + }) + .collect(); + + // Every field carries its own context, so nothing is needed from the + // caller — and one await seals the whole table: the `Vec` implementation + // merges every struct's pending, so five users (two ciphertexts and two + // terms each) settle in a single batched generate_keys call. + let table: Vec<EncryptedUser> = users.encrypt_into(&keyset).await?; + println!("stored {} encrypted users in one ZeroKMS call", table.len()); + + // --- Query side: terms only, no plaintext, no decryption ------------------ + + // Term probes derive under the index key the cipher already holds, under + // the same context the field was stored under: building a query never + // calls ZeroKMS at all. + + // WHERE age = 34: compare equality terms. + let probe: EqualityTerm = 34u32 + .encrypt_into_with_context(&keyset, nonempty!("users/age")) + .await?; + let equal: Vec<usize> = (0..table.len()) + .filter(|&i| table[i].age.eq == probe) + .collect(); + println!("WHERE age = 34 => rows {equal:?}"); + + // WHERE age > 40: compare ORE terms. + let bound: OreTerm<u32> = 40u32 + .encrypt_into_with_context(&keyset, nonempty!("users/age")) + .await?; + let over_40: Vec<usize> = (0..table.len()) + .filter(|&i| table[i].age.ord > bound) + .collect(); + println!("WHERE age > 40 => rows {over_40:?}"); + + // ORDER BY age: sort by ORE term. + let mut by_age: Vec<usize> = (0..table.len()).collect(); + by_age.sort_by(|&a, &b| table[a].age.ord.cmp(&table[b].age.ord)); + println!("ORDER BY age => rows {by_age:?}"); + + // --- Read side: decrypt only the rows a query matched --------------------- + + // A separate client: any process holding the same ZeroKMS credentials and + // keyset can decrypt what this one wrote. + let decryptor = StackCipher::new().await?; + + // Collect the matching rows and decrypt them together: one batched + // retrieve_keys call, however many rows matched. Each field opens under + // the context it was sealed under — it is bound into the AAD, so a + // ciphertext cannot be replayed against a different field. + let mut table = table; + let mut matches: Vec<EncryptedUser> = Vec::new(); + // Descending index order keeps earlier indices valid across swap_remove. + for i in over_40.into_iter().rev() { + matches.push(table.swap_remove(i)); + } + let matched: Vec<User> = Vec::<User>::decrypt_from(matches, &decryptor).await?; + for user in &matched { + println!("decrypted matching row: {user:?}"); + } + + // --- Binding a value to its record ---------------------------------------- + + // A context the caller passes *extends* every field's own: under the + // record's id, `age` is sealed under `("users/age", id)` and opens only + // there — a ciphertext can no longer be moved between records of the + // same table. The price is that its terms are scoped to that record too: + // a probe built under `"users/age"` alone never matches them, so extend + // where a value is read by id, not where it is searched across rows. + let id = 42u64; + let alice = User { + age: 34, + email: "alice@example.com".into(), + }; + let record: EncryptedUser = alice.clone().encrypt_into_with_context(&keyset, id).await?; + let unscoped: Vec<usize> = std::iter::once(&record) + .enumerate() + .filter(|(_, r)| r.age.eq == probe) + .map(|(i, _)| i) + .collect(); + println!( + "record-scoped terms match the table probe: {}", + !unscoped.is_empty() + ); + let scoped: EqualityTerm = 34u32 + .encrypt_into_with_context(&keyset, nonempty!("users/age").with(id)) + .await?; + println!( + " ...and a probe built under the same id: {}", + record.age.eq == scoped + ); + + let opened = User::decrypt_from_with_context(record, &decryptor, id).await?; + assert_eq!(opened, alice); + // The record id is in every field's context, and the context is the + // ZeroKMS descriptor of every data key: opening under another id fails + // at ZeroKMS, before any key material moves. (Against a source that + // does not enforce descriptors — the fake — the AEAD refuses instead.) + let record: EncryptedUser = alice.encrypt_into_with_context(&keyset, id).await?; + let wrong_id = User::decrypt_from_with_context(record, &decryptor, 43u64).await; + println!( + "opening under another id: {}", + match wrong_id { + Err(Error::Kms(e)) => format!("refused by ZeroKMS ({e})"), + Err(Error::Aead) => "refused (AEAD)".to_owned(), + _ => "unexpected".to_owned(), + } + ); + + Ok(()) +} diff --git a/packages/stack-encrypt/examples/mixed_user.rs b/packages/stack-encrypt/examples/mixed_user.rs new file mode 100644 index 000000000..40418e378 --- /dev/null +++ b/packages/stack-encrypt/examples/mixed_user.rs @@ -0,0 +1,195 @@ +//! A struct with a mix of encrypted and passthrough fields, encrypted as a +//! batch. +//! +//! `User` keeps `id` and `display_name` in the clear (passthrough) while +//! `email` and `age` are sealed — each encrypted leaf under its own ZeroKMS +//! data key. A `Vec<User>` encrypts in **one call and one batched +//! `generate_keys` round-trip**, producing a single ciphertext tree whose +//! shape (sequence of maps, entry keys) is authenticated by the AAD +//! derivation chain. +//! +//! Element *positions* are not. Every element of a sequence is sealed under +//! the same derived AAD — deliberately, since that is what lets a single row +//! of a batch decrypt on its own as `Element<T>` — so reordering the elements +//! of a stored sequence still verifies. Order and length are the caller's +//! obligation; if they matter, bind them into the AAD yourself or store the +//! index alongside the row. +//! +//! Passthrough values travel in the clear and are **not authenticated** — +//! use them for non-sensitive routing/display data only. +//! +//! This is the *cipher-directed* layer — vitaminc's `Encrypt` / `Decrypt` +//! driven by hand — one level below `#[derive(EncryptFrom)]`, which the +//! `encrypted_record` example uses. Reach for this layer when a value needs +//! what the derive does not express: fields stored in the clear beside +//! sealed ones, or a single ciphertext whose internal shape (this +//! sequence-of-maps) is what the AAD chain authenticates. +//! +//! Run with: +//! +//! ```sh +//! cargo run -p stack-encrypt --example mixed_user +//! ``` +//! +//! Talks to real ZeroKMS. On a developer machine, `npx stash auth login` is +//! sufficient: the cipher finds both the access token and the client key in +//! the CLI's profile directory. In CI, set `CS_CLIENT_ACCESS_KEY` / +//! `CS_WORKSPACE_CRN` and `CS_CLIENT_ID` / `CS_CLIENT_KEY` instead (see the +//! `zerokms_auth` example for the lookup order). + +use stack_encrypt::{ + Cipher, CipherText, Decipher, Decrypt, Encrypt, IntoAad, StackCipher, StackCipherText, + Unspecified, +}; +use vitaminc_aead::{DecipherVisitor, MapAccess, MapCipher, Passthrough}; + +// --- The record type --------------------------------------------------------- + +#[derive(Debug, PartialEq)] +struct User { + id: u32, // passthrough: visible in the stored ciphertext + display_name: String, // passthrough + email: String, // encrypted + age: u32, // encrypted +} + +impl Encrypt for User { + fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result<C::Ok, C::Error> + where + C: Cipher, + A: IntoAad<'a>, + { + // Keys travel in the clear; each *encrypted* value is sealed against + // an AAD derived from the map's AAD + its key, so entries cannot be + // renamed or swapped. Passthrough entries carry no such binding. + cipher + .encrypt_map(aad) + .encrypt_entry("id", Passthrough(self.id))? + .encrypt_entry("display_name", Passthrough(self.display_name))? + .encrypt_entry("email", self.email)? + .encrypt_entry("age", self.age)? + .end() + } +} + +impl<'c> Decrypt<'c> for User { + fn decrypt_with_aad<'a, D, A>(decipher: D, aad: A) -> D::Ok<Self> + where + D: Decipher<'c>, + A: IntoAad<'a>, + { + struct UserVisitor; + impl<'c> DecipherVisitor<'c> for UserVisitor { + type Value = User; + + fn visit_map<M: MapAccess<'c>>(self, mut map: M) -> Result<User, Unspecified> { + // Entries arrive in encryption order; each is pulled with its + // expected type and its key is checked. + fn entry<'c, M: MapAccess<'c>, T: Decrypt<'c> + 'c>( + map: &mut M, + key: &str, + ) -> Result<T, Unspecified> { + let (k, value) = map + .next_entry::<T>() + .map_err(|_| Unspecified)? + .ok_or(Unspecified)?; + if k == key { + Ok(value) + } else { + Err(Unspecified) + } + } + + let Passthrough(id) = entry::<_, Passthrough<u32>>(&mut map, "id")?; + let Passthrough(display_name) = + entry::<_, Passthrough<String>>(&mut map, "display_name")?; + let email: String = entry(&mut map, "email")?; + let age: u32 = entry(&mut map, "age")?; + Ok(User { + id, + display_name, + email, + age, + }) + } + } + decipher.decrypt_map(UserVisitor, aad) + } +} + +// --- Inspect what a server would see ----------------------------------------- + +fn describe(ciphertext: &StackCipherText, indent: usize) { + let pad = " ".repeat(indent); + match ciphertext { + CipherText::Sequence(items) => { + println!("{pad}sequence of {} rows:", items.len()); + for item in items { + describe(item, indent + 1); + } + } + CipherText::Map(entries) => { + for (key, value) in entries { + match value { + CipherText::Passthrough(boxed) => { + // Passthrough values are readable without any key. + if let Some(v) = boxed.downcast_ref::<u32>() { + println!("{pad}{key}: {v} (passthrough, in the clear)"); + } else if let Some(v) = boxed.downcast_ref::<String>() { + println!("{pad}{key}: {v:?} (passthrough, in the clear)"); + } + } + CipherText::Single(_) => { + println!("{pad}{key}: <ciphertext under its own data key>"); + } + _ => println!("{pad}{key}: <nested>"), + } + } + } + _ => {} + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box<dyn std::error::Error>> { + let cipher = StackCipher::new().await?; + + let users = vec![ + User { + id: 1, + display_name: "alice".into(), + email: "alice@example.com".into(), + age: 34, + }, + User { + id: 2, + display_name: "bob".into(), + email: "bob@example.com".into(), + age: 41, + }, + User { + id: 3, + display_name: "carol".into(), + email: "carol@example.com".into(), + age: 29, + }, + ]; + + // One call, one batched generate_keys round-trip for every encrypted leaf + // in the whole Vec (here: 3 rows x 2 encrypted fields = 6 data keys). + let ciphertext = cipher.default_keyset().encrypt(users, "users/v1").await?; + + println!("what the stored ciphertext reveals:"); + describe(&ciphertext, 1); + + // One batched retrieve_keys round-trip, then a crypto-free structural + // decode back into the typed rows. The AAD must match the encrypt call. + let users: Vec<User> = cipher.decrypt(ciphertext, "users/v1").await?; + + println!("\ndecrypted rows:"); + for user in &users { + println!(" {user:?}"); + } + + Ok(()) +} diff --git a/packages/stack-encrypt/examples/search_terms.rs b/packages/stack-encrypt/examples/search_terms.rs new file mode 100644 index 000000000..bf44ad5e1 --- /dev/null +++ b/packages/stack-encrypt/examples/search_terms.rs @@ -0,0 +1,166 @@ +//! Searchable Encrypted Metadata, leaf by leaf. +//! +//! Shows each index-term primitive on its own through the target-directed +//! `encrypt_into` API: equality terms (exact match), match terms (full-text +//! containment), and ORE/OPE terms (range queries) — all generated locally +//! from a deterministic per-keyset index key, then compared the way a server +//! would compare them: without ever seeing a plaintext. +//! +//! Run with: +//! +//! ```sh +//! cargo run -p stack-encrypt --example search_terms +//! ``` +//! +//! Talks to real ZeroKMS. On a developer machine, `npx stash auth login` is +//! sufficient: the cipher finds both the access token and the client key in +//! the CLI's profile directory. In CI, set `CS_CLIENT_ACCESS_KEY` / +//! `CS_WORKSPACE_CRN` and `CS_CLIENT_ID` / `CS_CLIENT_KEY` instead (see the +//! `zerokms_auth` example for the lookup order). + +use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; +use stack_encrypt::target::EncryptInto; +use stack_encrypt::{nonempty, EncryptFrom, StackCipher, StackCipherText}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box<dyn std::error::Error>> { + // `StackCipher::new()` builds a ZeroKMS client from the environment and + // loads the keyset's index key once, during construction. + let cipher = StackCipher::new().await?; + // Terms bind to a keyset: this handle derives every term under the + // default keyset's index key. + let terms = cipher.default_keyset(); + println!("cipher ready on keyset {}", terms.keyset_id()); + + // One cipher serves write time and query time; terms are deterministic + // under the same index key + context, which is what makes them queryable. + // Deriving a term touches no data keys, so a query builder never calls + // ZeroKMS to build a probe. + + // --- Equality: exact-match lookups -------------------------------------- + // + // The context ("users/email") domain-separates terms per field: the same + // value indexed under another field can never produce a colliding term. + + let stored: EqualityTerm = "alice@example.com" + .encrypt_into_with_context(&terms, nonempty!("users/email")) + .await?; + + let hit: EqualityTerm = "alice@example.com" + .encrypt_into_with_context(&terms, nonempty!("users/email")) + .await?; + let miss: EqualityTerm = "bob@example.com" + .encrypt_into_with_context(&terms, nonempty!("users/email")) + .await?; + let wrong_field: EqualityTerm = "alice@example.com" + .encrypt_into_with_context(&terms, nonempty!("users/name")) + .await?; + + println!("\nequality:"); + println!(" same value, same field => match: {}", stored == hit); + println!(" different value => match: {}", stored == miss); + println!( + " same value, other field => match: {}", + stored == wrong_field + ); + + // --- Match: full-text containment --------------------------------------- + // + // Text is tokenized locally (3-grams by default), each token is PRF'd, and + // the outputs fold into Bloom-filter bit positions. A query matches when + // its positions are a subset of the stored term's (Bloom semantics: false + // positives possible, false negatives not). + + let bio: MatchTerm = "alice, senior cryptography engineer" + .to_string() + .encrypt_into_with_context(&terms, nonempty!("users/bio")) + .await?; + + for query in ["crypto", "engineer", "plumber"] { + let probe: MatchTerm = query + .to_string() + .encrypt_into_with_context(&terms, nonempty!("users/bio")) + .await?; + println!("match: bio contains {query:?} => {}", bio.contains(&probe)); + } + println!( + " (stored term is just bit positions: {:?} ...)", + &bio.positions()[..bio.positions().len().min(8)] + ); + + // --- ORE: range queries -------------------------------------------------- + // + // CLLW ORE ciphertexts compare like their plaintexts. The per-field ORE + // key is derived *through the PRF* from the context alone — the plaintext + // never enters the PRF, so under the coming 2-party ZeroKMS PRF backend + // the key derivation becomes an auditable server event while values stay + // local. + + let age_30: OreTerm<u32> = 30u32 + .encrypt_into_with_context(&terms, nonempty!("users/age")) + .await?; + let age_45: OreTerm<u32> = 45u32 + .encrypt_into_with_context(&terms, nonempty!("users/age")) + .await?; + let query_40: OreTerm<u32> = 40u32 + .encrypt_into_with_context(&terms, nonempty!("users/age")) + .await?; + + println!("\nore (WHERE age > 40):"); + println!(" age 30 > 40 => {}", age_30 > query_40); + println!(" age 45 > 40 => {}", age_45 > query_40); + + // Strings order lexicographically. + let apple: OreTerm<&str> = "apple" + .encrypt_into_with_context(&terms, nonempty!("users/name")) + .await?; + let banana: OreTerm<&str> = "banana" + .encrypt_into_with_context(&terms, nonempty!("users/name")) + .await?; + println!(" \"apple\" < \"banana\" => {}", apple < banana); + + // --- The same terms, as a record -------------------------------------------- + // + // Leaf by leaf is the query side. On the write side a field is stored + // as *every* term it needs beside its ciphertext, in one shape: derive + // `EncryptFrom` for that shape and each field of it is derived from the + // one value under the one context — byte-identical to the leaves above, + // so a probe built leaf by leaf finds what the record stored. + #[derive(EncryptFrom)] + #[stash(plaintext = String)] + struct SearchableEmail { + c: StackCipherText, + eq: EqualityTerm, + text: MatchTerm, + ord: OreTerm<String>, + } + + let record: SearchableEmail = "alice@example.com" + .to_string() + .encrypt_into_with_context(&terms, nonempty!("users/email")) + .await?; + let probe: MatchTerm = "example" + .to_string() + .encrypt_into_with_context(&terms, nonempty!("users/email")) + .await?; + println!("\nrecord:"); + println!( + " equality term equals the leaf's => {}", + record.eq == stored + ); + println!( + " match: contains \"example\" => {}", + record.text.contains(&probe) + ); + println!( + " ore: sorts after \"alice\" => {}", + record.ord + > "alice" + .to_string() + .encrypt_into_with_context(&terms, nonempty!("users/email")) + .await? + ); + let _ = record.c; // the ciphertext, opened with `decrypt_into` under the same context + + Ok(()) +} diff --git a/packages/stack-encrypt/examples/zerokms_auth.rs b/packages/stack-encrypt/examples/zerokms_auth.rs new file mode 100644 index 000000000..80fbea377 --- /dev/null +++ b/packages/stack-encrypt/examples/zerokms_auth.rs @@ -0,0 +1,118 @@ +//! Wiring a cipher to real ZeroKMS, and to a custom authentication strategy. +//! +//! Every example talks to real ZeroKMS. This one shows where the credentials +//! come from, and how to substitute your own when the defaults do not fit. +//! +//! Run with: +//! +//! ```sh +//! cargo run -p stack-encrypt --example zerokms_auth +//! ``` +//! +//! On a developer machine, `npx stash auth login` is sufficient. Without any +//! credentials it prints what it *would* do and exits — so it is safe to run +//! anywhere, and CI builds it either way. + +use stack_auth::{AuthError, AuthStrategyFn, SecretToken, ServiceToken}; +use stack_encrypt::StackCipher; +use stack_kms::{EnvKeyProvider, StackKmsBuilder}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box<dyn std::error::Error>> { + // --- The default: credentials from the environment ---------------------- + // + // `StackCipher::new()` is `StackKmsBuilder::auto()` plus a client key, + // plus a keyset resolution. Both credentials are looked up the same way — + // environment first, then the current workspace in the CLI's profile + // directory (`~/.cipherstash`, written by `npx stash auth login`): + // + // access token: CS_CLIENT_ACCESS_KEY + CS_WORKSPACE_CRN, else auth.json + // client key: CS_CLIENT_ID + CS_CLIENT_KEY, else secretkey.json + // + // So a logged-in developer machine needs nothing else; CI sets the four + // variables. + let cipher = match StackCipher::new().await { + Ok(cipher) => cipher, + // Nothing to connect to: say so and exit cleanly, so the example is + // safe to run anywhere. + Err(stack_encrypt::Error::Config(why)) => { + println!("not configured for ZeroKMS: {why}"); + println!( + "run `npx stash auth login`, or set CS_CLIENT_ACCESS_KEY / CS_WORKSPACE_CRN \ + and CS_CLIENT_ID / CS_CLIENT_KEY, to run this." + ); + return Ok(()); + } + // Configured but not accepted — typically a stale device session in + // ~/.cipherstash or an access key for another workspace. `auto()` only + // checks that a strategy *exists*; ZeroKMS is the first to say no. + Err(stack_encrypt::Error::Kms(why)) => { + println!("could not reach or authenticate with ZeroKMS: {why}"); + println!("check the credentials `auto()` detected (CS_* variables, ~/.cipherstash)."); + return Ok(()); + } + Err(other) => return Err(other.into()), + }; + let keyset = cipher.default_keyset(); + println!("connected; keyset {}", keyset.keyset_id()); + + let ciphertext = keyset.encrypt("hello", "demo/greeting").await?; + let plaintext: String = cipher.decrypt(ciphertext, "demo/greeting").await?; + assert_eq!(plaintext, "hello"); + println!("round-tripped a value under the default keyset"); + + // --- A specific keyset -------------------------------------------------- + // + // A cipher is client-scoped and serves any keyset the client is + // authorised for; selecting one (loaded from ZeroKMS on first use, then + // cached) yields a handle that pins both halves at once: data keys are + // generated under it, and its index key derives every SEM term. They + // cannot diverge. + // + // let customers = cipher + // .keyset(IdentifiedBy::Name("customers".to_string().into())) + // .await?; + // let ciphertext = customers.encrypt("hello", "demo/greeting").await?; + // + // `default_keyset()` above is not one of these: it is the client's own + // default, the keyset a ZeroKMS administrator set for this client, and + // selecting others never moves it. + + // --- A custom authentication strategy ----------------------------------- + // + // When the built-in strategies do not fit — tokens from your own broker, a + // sidecar, a test double — build the `StackKms` yourself and hand it to + // the builder. Any `AuthStrategy` works; `AuthStrategyFn` wraps a closure. + // + // The same seam takes `AccessKeyStrategy`, `DeviceSessionStrategy`, or an + // OIDC federation strategy when you want to name one explicitly rather + // than let `auto()` detect it. + // This section needs a real token: building the cipher resolves the keyset + // and loads its index key, which is a round-trip that must authenticate. + // + // Note what the token is held in: `SecretToken` wraps it the moment it is + // read, and that wrapper — not a bare `String` — is what the closure + // captures and clones. `SecretToken` is zeroized on drop and prints as + // `***`, so a long-lived credential neither lingers in freed memory nor + // lands in a log line. + let Ok(token) = std::env::var("MY_SERVICE_TOKEN").map(SecretToken::new) else { + println!("MY_SERVICE_TOKEN not set; skipping the custom-strategy section."); + return Ok(()); + }; + let strategy = AuthStrategyFn::new(move || { + // Your token source: a broker, a sidecar, a cached credential. Called + // whenever ZeroKMS needs a fresh token, so refresh belongs in here. + let token = token.clone(); + async move { Ok::<_, AuthError>(ServiceToken::new(token)) } + }); + + let kms = StackKmsBuilder::new(strategy) + .with_key_provider(EnvKeyProvider) + .build() + .await?; + + let _cipher = StackCipher::builder().kms(kms).init().await?; + println!("built a second cipher over a custom auth strategy"); + + Ok(()) +} diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock new file mode 100644 index 000000000..32e39436e --- /dev/null +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -0,0 +1,3236 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cached" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.5", + "once_cell", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cipherstash-config" +version = "0.42.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d098935e395d7346d0cdc8cdf3ed9674ab03fa8b415e828d02e65c81836a73c" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cllw-ore" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476f300d37a5029d3d9dd57145d4db50a23f40ae9b4d1374c44543978b906191" +dependencies = [ + "blake3", + "hex", + "subtle", + "thiserror 1.0.69", + "unicode-normalization", + "zeroize", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest 0.10.7", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-hex" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cts-common" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cb0f5ffa463e8facbe6ad78cfe925d132a051c6b1c9a5da2f3961296b7e632" +dependencies = [ + "arrayvec", + "base32", + "cached", + "chrono", + "derive_more", + "either", + "getrandom 0.4.2", + "miette", + "nom", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "utoipa", + "uuid", + "vitaminc", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", + "ctutils", + "zeroize", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dummy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fake" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" +dependencies = [ + "deunicode", + "dummy", + "rand 0.8.6", + "uuid", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix", + "windows-targets 0.52.6", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mutants" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.3", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "recipher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e156e2d485b51cc67c19241e7d81ad524bda9fd4f77791b698ab10c8e26e9" +dependencies = [ + "aes", + "cmac", + "getrandom 0.2.17", + "hex", + "hex-literal", + "opaque-debug", + "rand 0.8.6", + "rand_chacha 0.3.1", + "serde", + "serde_cbor", + "sha2 0.10.9", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", + "zeroize", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.42.3" +dependencies = [ + "aquamarine", + "base64", + "cts-common", + "jsonwebtoken", + "miette", + "open", + "serde", + "serde_json", + "serde_urlencoded", + "stack-profile", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "web-time", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-encrypt" +version = "0.1.0" +dependencies = [ + "base64ct", + "cllw-ore", + "serde", + "stack-encrypt-derive", + "stack-kms", + "thiserror 1.0.69", + "uuid", + "vitaminc-aead", + "vitaminc-aead-value", + "vitaminc-encrypt", + "vitaminc-hmac", + "vitaminc-prf", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "stack-encrypt-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "stack-encrypt-fuzz" +version = "0.0.0" +dependencies = [ + "arbitrary", + "libfuzzer-sys", + "stack-encrypt", + "uuid", +] + +[[package]] +name = "stack-kms" +version = "0.1.0" +dependencies = [ + "base16ct", + "base64ct", + "blake3", + "futures", + "miette", + "opaque-debug", + "recipher", + "serde", + "serde_cbor", + "serde_json", + "serdect", + "sha2 0.10.9", + "stack-auth", + "stack-profile", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-profile" +version = "0.42.3" +dependencies = [ + "dirs", + "gethostname", + "serde", + "serde_json", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "url", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "atomic", + "getrandom 0.3.4", + "js-sys", + "md-5", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vitaminc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +dependencies = [ + "vitaminc-aead", + "vitaminc-context", + "vitaminc-encrypt", + "vitaminc-protected", + "vitaminc-random", + "vitaminc-traits", +] + +[[package]] +name = "vitaminc-aead" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +dependencies = [ + "bytes", + "serde", + "vitaminc-aead-derive", + "vitaminc-context", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-aead-value" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +dependencies = [ + "vitaminc-aead", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-context" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +dependencies = [ + "mutants", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-hmac" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" +dependencies = [ + "hmac", + "sha2 0.11.0", + "vitaminc-prf", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-prf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" +dependencies = [ + "mutants", + "thiserror 2.0.18", + "vitaminc-context", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-protected" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.18", + "vitaminc-protected-derive", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-random" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random-derives", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-traits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerokms-protocol" +version = "0.12.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c28e88315a5109d0a1e7ee4b7b4b8776a0bff5f5b139ae83960a3debe84e92e" +dependencies = [ + "base64", + "cipherstash-config", + "const-hex", + "cts-common", + "fake", + "getrandom 0.2.17", + "opaque-debug", + "rand 0.8.6", + "serde", + "static_assertions", + "thiserror 1.0.69", + "utoipa", + "uuid", + "validator", + "zeroize", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/packages/stack-encrypt/fuzz/Cargo.toml b/packages/stack-encrypt/fuzz/Cargo.toml new file mode 100644 index 000000000..a551e631a --- /dev/null +++ b/packages/stack-encrypt/fuzz/Cargo.toml @@ -0,0 +1,55 @@ +# Fuzz crate for stack-encrypt's frozen byte decoders. +# +# This is a DETACHED crate: the `[workspace]` table at the bottom makes it its +# own workspace root so the libfuzzer-sys dependency and the nightly-only build +# never touch the main monorepo workspace. It is not a member of the root +# workspace (see the root Cargo.toml `members` list). Run via the `fuzz:*` +# mise tasks, which invoke `cargo +nightly fuzz run`. +[package] +name = "stack-encrypt-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +# Structure-aware inputs for `check_record`: the mirror types derive +# `Arbitrary`, so the fuzzer mutates trees and plans, not bytes. +arbitrary = { version = "1", features = ["derive"] } +# The fixed leaf the record harness fills its sealed nodes with. +uuid = "1.8" + +[dependencies.stack-encrypt] +path = ".." +# The decoders are structural and need no ZeroKMS client: with `http` off, +# reqwest and its TLS stack stay out of the fuzz build. +default-features = false +# `dynamic`: the record plan and preflight the `check_record` target drives. +features = ["dynamic"] + +[[bin]] +name = "sealed_value_decode" +path = "fuzz_targets/sealed_value_decode.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "term_decode" +path = "fuzz_targets/term_decode.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "check_record" +path = "fuzz_targets/check_record.rs" +test = false +doc = false +bench = false + +[workspace] +resolver = "2" diff --git a/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-accepted b/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-accepted new file mode 100644 index 000000000..4c60b0f33 Binary files /dev/null and b/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-accepted differ diff --git a/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-passthrough-under-c-refused b/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-passthrough-under-c-refused new file mode 100644 index 000000000..f7788462d Binary files /dev/null and b/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-passthrough-under-c-refused differ diff --git a/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1 b/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1 new file mode 100644 index 000000000..663308089 Binary files /dev/null and b/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1 differ diff --git a/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1-empty-tag-and-body b/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1-empty-tag-and-body new file mode 100644 index 000000000..a59355660 Binary files /dev/null and b/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1-empty-tag-and-body differ diff --git a/packages/stack-encrypt/fuzz/corpus/term_decode/valid-equality-term b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-equality-term new file mode 100644 index 000000000..821c948ef Binary files /dev/null and b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-equality-term differ diff --git a/packages/stack-encrypt/fuzz/corpus/term_decode/valid-match-term b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-match-term new file mode 100644 index 000000000..4c2cac3c1 Binary files /dev/null and b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-match-term differ diff --git a/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ope-string-term b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ope-string-term new file mode 100644 index 000000000..0142b36bc Binary files /dev/null and b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ope-string-term differ diff --git a/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-string-term b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-string-term new file mode 100644 index 000000000..cced39c73 --- /dev/null +++ b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-string-term @@ -0,0 +1 @@ +Ê#ºezàɊMÊ߁°ÐýÉe›z„LÍ"®]\¼Ì *W^˜‘ \ No newline at end of file diff --git a/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-u64-term b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-u64-term new file mode 100644 index 000000000..8fac046e9 --- /dev/null +++ b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-u64-term @@ -0,0 +1 @@ +åøU�Â×ÝÝl[qN�(U†¡¸9 ‘@Bx�JϵýÔí@v֕,!-ÚÙƠas/Žñ´ I×.Ÿ·웲“ \ No newline at end of file diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs new file mode 100644 index 000000000..13c7d67ff --- /dev/null +++ b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs @@ -0,0 +1,274 @@ +#![no_main] + +//! The stored-record preflight, `dynamic::record::check_record`, against a +//! model of its documented rules. +//! +//! This is the walk a binding's decrypt runs before any key is requested: +//! it decides whether a stored tree fits its plan, and it is the only thing +//! standing between a tree an attacker rewrote and `decrypt_as`, which +//! opens no AEAD for a passthrough and would hand forged plaintext back as +//! a successful decrypt. So the input is not bytes but a *structure*: an +//! `Arbitrary`-derived mirror of a plan value and of a ciphertext tree, +//! drawn from a small name alphabet so field names, output keys and map +//! keys collide often, with passthroughs and duplicate keys anywhere. +//! +//! Two invariants. Neither `plan` nor `check_record` may panic on any +//! input. And for a plan that parses, `check_record` accepts the tree +//! exactly when the model does — the rules from the record docs, written +//! independently of the walk: one map, or a sequence of maps; every +//! ciphertext-bearing plan field present exactly once in every row; that +//! field a map with exactly one `"c"`; and under that `"c"` no passthrough +//! and no repeated map key at any depth. + +use std::sync::OnceLock; + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use stack_encrypt::dynamic::record::{check_record, plan, Plan}; +use stack_encrypt::dynamic::FfiValue; +use stack_encrypt::{SealedValue, StackCipherText}; +use uuid::Uuid; + +/// The name alphabet: the plan's field names, the tree's map keys and the +/// output keys all draw from it, so `"c"` is at once an output key and a +/// plausible field name. +#[derive(Arbitrary, Debug, Clone, Copy, PartialEq, Eq)] +enum Name { + A, + B, + C, + Eq, + Match, + Ore, + Ope, + Other, +} + +impl Name { + fn as_str(self) -> &'static str { + match self { + Name::A => "a", + Name::B => "b", + Name::C => "c", + Name::Eq => "eq", + Name::Match => "match", + Name::Ore => "ore", + Name::Ope => "ope", + Name::Other => "zz", + } + } +} + +/// A `"context"` value: the shapes `dynamic::context` accepts, plus one it +/// refuses. +#[derive(Arbitrary, Debug)] +enum Ctx { + Text(Name), + I64(i64), + U32(u32), + List(Vec<Ctx>), + Bool(bool), +} + +impl Ctx { + fn into_value(self) -> FfiValue { + match self { + Ctx::Text(name) => FfiValue::String(name.as_str().into()), + Ctx::I64(v) => FfiValue::Int64(v), + Ctx::U32(v) => FfiValue::UInt32(v), + Ctx::List(items) => FfiValue::Array(items.into_iter().map(Ctx::into_value).collect()), + Ctx::Bool(v) => FfiValue::Bool(v), + } + } +} + +/// One entry of a field spec: the two keys the parser knows and one it +/// does not, each with a value that may or may not be the right shape. +#[derive(Arbitrary, Debug)] +enum SpecEntry { + Context(Ctx), + Outputs(Vec<Name>), + ContextWrongShape(u32), + OutputsWrongShape(u32), + Unknown(Ctx), +} + +impl SpecEntry { + fn into_entry(self) -> (String, FfiValue) { + match self { + SpecEntry::Context(ctx) => ("context".to_string(), ctx.into_value()), + SpecEntry::Outputs(names) => ( + "outputs".to_string(), + FfiValue::Array( + names + .into_iter() + .map(|n| FfiValue::String(n.as_str().into())) + .collect(), + ), + ), + SpecEntry::ContextWrongShape(v) => ("context".to_string(), FfiValue::UInt32(v)), + SpecEntry::OutputsWrongShape(v) => ("outputs".to_string(), FfiValue::UInt32(v)), + SpecEntry::Unknown(ctx) => ("bogus".to_string(), ctx.into_value()), + } + } +} + +/// A plan value as a binding would send it: `{ field: { ...spec } }`. +#[derive(Arbitrary, Debug)] +struct PlanSpec { + fields: Vec<(Name, Vec<SpecEntry>)>, +} + +impl PlanSpec { + fn into_value(self) -> FfiValue { + FfiValue::Object( + self.fields + .into_iter() + .map(|(name, entries)| { + ( + name.as_str().to_string(), + FfiValue::Object(entries.into_iter().map(SpecEntry::into_entry).collect()), + ) + }) + .collect(), + ) + } +} + +/// A stored ciphertext tree, with every `CipherText` variant reachable. +#[derive(Arbitrary, Debug)] +enum Tree { + Single, + None, + EmptySequence, + EmptyMap, + Passthrough, + Sequence(Vec<Tree>), + Map(Vec<(Name, Tree)>), +} + +/// The one leaf every sealed node carries. Structural only, as +/// `check_record` is: nothing here opens it. +fn leaf() -> SealedValue { + static LEAF: OnceLock<SealedValue> = OnceLock::new(); + LEAF.get_or_init(|| { + SealedValue::from_parts(Uuid::nil(), [0; 16], vec![0; 48], vec![1; 32]) + .expect("a fixed tag fits the length field") + }) + .clone() +} + +impl Tree { + fn into_ciphertext(self) -> StackCipherText { + match self { + Tree::Single => StackCipherText::Single(leaf()), + Tree::None => StackCipherText::None(leaf()), + Tree::EmptySequence => StackCipherText::EmptySequence(leaf()), + Tree::EmptyMap => StackCipherText::EmptyMap(leaf()), + Tree::Passthrough => StackCipherText::Passthrough(Box::new(FfiValue::Null)), + Tree::Sequence(items) => { + StackCipherText::Sequence(items.into_iter().map(Tree::into_ciphertext).collect()) + } + Tree::Map(entries) => StackCipherText::Map( + entries + .into_iter() + .map(|(name, node)| (name.as_str().to_string(), node.into_ciphertext())) + .collect(), + ), + } + } + + /// A subtree fit to sit under `"c"`: no passthrough, and no map with a + /// key given twice, at any depth. + fn is_clean(&self) -> bool { + match self { + Tree::Passthrough => false, + Tree::Sequence(items) => items.iter().all(Tree::is_clean), + Tree::Map(entries) => { + let unique = entries + .iter() + .enumerate() + .all(|(at, (name, _))| !entries[..at].iter().any(|(prior, _)| prior == name)); + unique && entries.iter().all(|(_, node)| node.is_clean()) + } + Tree::Single | Tree::None | Tree::EmptySequence | Tree::EmptyMap => true, + } + } +} + +/// The model: the record rules as documented, not as implemented. +fn model_accepts(tree: &Tree, plan: &Plan) -> bool { + let rows: Vec<&[(Name, Tree)]> = match tree { + Tree::Map(entries) => vec![entries], + Tree::Sequence(items) => { + let mut rows = Vec::with_capacity(items.len()); + for item in items { + let Tree::Map(entries) = item else { + return false; + }; + rows.push(entries.as_slice()); + } + rows + } + _ => return false, + }; + rows.iter().all(|row| { + plan.fields() + .iter() + .filter(|field| field.has_ciphertext()) + .all(|field| { + // The field exactly once in the row, and `"c"` exactly once + // in its output map: a second copy is how a stale ciphertext + // would be smuggled in beside the current one. + let mut named = row + .iter() + .filter(|(name, _)| name.as_str() == field.name()) + .map(|(_, node)| node); + let (Some(Tree::Map(outputs)), None) = (named.next(), named.next()) else { + return false; + }; + let mut cs = outputs + .iter() + .filter(|(name, _)| *name == Name::C) + .map(|(_, node)| node); + let (Some(ct), None) = (cs.next(), cs.next()) else { + return false; + }; + ct.is_clean() + }) + }) +} + +#[derive(Arbitrary, Debug)] +struct Case { + plan: PlanSpec, + record: Tree, +} + +fuzz_target!(|case: Case| { + // Replaying one input with this set shows what it decoded to and how it + // was judged: `STACK_ENCRYPT_FUZZ_TRACE=1 cargo +nightly fuzz run + // check_record <input>`. Off during a campaign. + let trace = std::env::var_os("STACK_ENCRYPT_FUZZ_TRACE").is_some(); + if trace { + eprintln!("case: {case:#?}"); + } + let Case { plan: spec, record } = case; + // The plan parser is fuzzed for panics only: its rules are a separate + // model, and a plan that does not parse has no record to check. + let Ok(plan) = plan(spec.into_value()) else { + if trace { + eprintln!("verdict: plan refused"); + } + return; + }; + let expected = model_accepts(&record, &plan); + let actual = check_record(record.into_ciphertext(), &plan).is_ok(); + if trace { + eprintln!("verdict: model accepts = {expected}, check_record accepts = {actual}"); + } + assert_eq!( + actual, expected, + "check_record disagrees with the documented rules (model says accepted = {expected})" + ); +}); diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/sealed_value_decode.rs b/packages/stack-encrypt/fuzz/fuzz_targets/sealed_value_decode.rs new file mode 100644 index 000000000..a38487378 --- /dev/null +++ b/packages/stack-encrypt/fuzz/fuzz_targets/sealed_value_decode.rs @@ -0,0 +1,18 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use stack_encrypt::SealedValue; + +// Fuzz the frozen v1 leaf byte decoder. A stored ciphertext column comes back +// through `SealedValue::from_bytes` before anything is authenticated, so the +// bytes are attacker-controlled: decoding must never panic, only return `Err`. +// +// The format is documented as lossless, so a second invariant is checked on +// every accepted input: re-encoding gives back exactly the bytes decoded. +// A decoder that accepts bytes it cannot reproduce would let two distinct +// stored forms alias one leaf. +fuzz_target!(|bytes: &[u8]| { + if let Ok(leaf) = SealedValue::from_bytes(bytes) { + assert_eq!(leaf.to_bytes(), bytes, "SealedValue decode is not lossless"); + } +}); diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs b/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs new file mode 100644 index 000000000..45d7c0b0e --- /dev/null +++ b/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs @@ -0,0 +1,45 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerm, OpeTerm, OreTerm}; + +// Fuzz the SEM index-term byte decoders: stored terms come back through these +// before comparison, so the bytes are attacker-controlled and decoding must +// never panic. Every kind is run over the same slice; each is a length or +// range check and a crash names its kind in the panic message. +// +// The unframed kinds (equality, ORE, OPE) store the bytes as given, so an +// accepted input must re-encode to itself. A match term normalises its +// positions (sorted, de-duplicated), so only its decode is exercised. +fuzz_target!(|bytes: &[u8]| { + if let Ok(term) = EqualityTerm::try_from(bytes) { + assert_eq!( + term.as_bytes(), + bytes, + "EqualityTerm decode is not lossless" + ); + } + let _ = MatchTerm::<DefaultMatch>::from_bytes(bytes); + // One fixed-width and one variable-width CLLW output each for ORE and OPE. + if let Ok(term) = OreTerm::<u64>::from_bytes(bytes) { + assert_eq!( + term.as_bytes(), + bytes, + "OreTerm<u64> decode is not lossless" + ); + } + if let Ok(term) = OreTerm::<String>::from_bytes(bytes) { + assert_eq!( + term.as_bytes(), + bytes, + "OreTerm<String> decode is not lossless" + ); + } + if let Ok(term) = OpeTerm::<String>::from_bytes(bytes) { + assert_eq!( + term.as_bytes(), + bytes, + "OpeTerm<String> decode is not lossless" + ); + } +}); diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs new file mode 100644 index 000000000..db7688e74 --- /dev/null +++ b/packages/stack-encrypt/src/cipher.rs @@ -0,0 +1,2047 @@ +//! Implementation of [`StackCipher`]. For usage, start at the crate docs; this +//! module documents the internals. +//! +//! `StackCipher` is scoped to one ZeroKMS client; a [`KeysetCipher`] — the +//! cipher bound to one of that client's keysets — is a vitaminc [`Cipher`] +//! whose per-leaf keys are ZeroKMS data keys, minted under that keyset, +//! rather than one fixed key. Structurally it mirrors +//! `vitaminc_encrypt::Aes256Cipher`: encrypting an [`Encrypt`] value produces +//! a recursive ciphertext tree ([`StackCipherText`], the analog of +//! `AesCipherText`) whose leaves ([`SealedValue`]) each carry the ZeroKMS +//! metadata for their own data key — the keyset it was minted under +//! included, which is why decrypting needs no keyset named and lives on the +//! client-scoped `StackCipher`. +//! +//! ## Batching the key fetch +//! +//! Data-key generation/retrieval is an async ZeroKMS round-trip, so the key +//! fetch cannot happen inside the synchronous [`Cipher`]/[`Decipher`] trait +//! methods. It is front-loaded on both sides; the AES work stays inside the +//! trait drive: +//! +//! * **Encrypt** — driving the [`Cipher`] trait over a `&KeysetCipher` builds +//! a *pending* tree ([`PendingStackCipherText`]) that holds plaintext plus +//! each leaf's fully derived AAD, but does no I/O. A single +//! [`PendingStackCipherText::seal`] (or the [`KeysetCipher::encrypt`] +//! convenience) then batches **one** `generate_keys` call for the whole +//! tree, under the handle's keyset, and seals every leaf. +//! * **Decrypt** — [`StackCipher::decrypt`] batches **one** `retrieve_keys` +//! call per keyset the leaves were sealed under and zips each key onto its +//! leaf, building a [`StackDecipher`] it does not hand out. The +//! value's [`Decrypt`] impl then drives that decipher exactly as it would +//! `AesDecipher`: each leaf is opened under the AAD the drive supplies, so +//! the visitor pattern (nested `Vec`/`HashMap`/`Option`/`Protected` values, +//! and AAD-deriving wrappers such as `vitaminc_aead::Element`) works +//! identically to `Aes256Cipher`. +//! +//! ## AAD derivation +//! +//! The caller's AAD is refined per node with the same domain-separated +//! derivations `Aes256Cipher` uses, so the container *shape* is authenticated: +//! +//! * sequence elements are sealed under [`Context::for_sequence_element`]; +//! * map values under [`Context::for_map_entry`] of their cleartext key (so +//! swapping or renaming keys fails decryption); +//! * authenticated-absent markers under [`Context::for_none`], and empty +//! sequences/maps under [`Context::for_empty_sequence`]/[`Context::for_empty_map`] — +//! each sealing an *empty* plaintext, verified as empty on open. +//! +//! The decrypt side performs the same derivations inside [`StackDecipher`]'s +//! `decrypt_seq`/`decrypt_map`/`decrypt_option` as the caller's `Decrypt` impl +//! drives it, so there is a single source of truth for the per-node AAD. +//! +//! ## Leaf crypto and wire format +//! +//! Each leaf ([`SealedValue`]) stores the ZeroKMS keyset id, `iv` and key +//! `tag` — enough to retrieve the data key — plus a vitaminc +//! [`LocalCipherText`] sealed under that key by +//! [`vitaminc_encrypt::Aes256Cipher`] (AES-256-GCM via vitaminc's backend: +//! `aws-lc-rs` on native, RustCrypto on wasm32; vitaminc's own random nonce +//! and versioned leaf layout). The leaf AAD is the labelled derivation +//! `leaf_aad` (private): +//! `PAE("stack-encrypt/leaf", version, keyset_id, derived_aad, tag)`, with +//! [`SealedValue::FORMAT_VERSION`] — the version byte that prefixes the +//! leaf's frozen byte encoding ([`SealedValue::to_bytes`]) — and the keyset +//! id bound under the tag, so a stored leaf relabelled with a different +//! version byte fails verification instead of selecting different parsing +//! rules, and one re-pointed at another keyset fails instead of asking that +//! keyset for a key it never minted. The `tag` is always bound, so the +//! ciphertext is cryptographically tied to its ZeroKMS data key (key +//! binding); a caller AAD (e.g. a [`ContextTag`](vitaminc_aead::ContextTag)) +//! adds a further binding layer. +//! Every data key is requested under a ZeroKMS **descriptor**: the context +//! the tree is sealed under, rendered as a string by +//! [`Descriptor`]. ZeroKMS HMACs the descriptor into the +//! key `tag` and demands the same descriptor to re-derive the key, so the +//! binding the leaf AAD makes locally is enforced at ZeroKMS as well, and +//! the descriptor is what ZeroKMS logs per retrieval. The lock context on +//! each request is empty. +//! +//! This is a fresh framing and is intentionally **not** byte-compatible with +//! `cipherstash-client`'s `EncryptedRecord` AAD (a raw `descriptor || tag` +//! concatenation). A compatibility module can be added later to read existing +//! records as customers of `cipherstash-client` migrate. + +use std::any::Any; +use std::borrow::Cow; +use std::collections::HashSet; +use std::num::NonZeroUsize; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use stack_kms::{DataKey, DataKeySource, DataKeyWithTag, IdentifiedBy, IndexKeySource}; +#[cfg(feature = "http")] +use stack_kms::{EnvKeyProvider, StackKms, StackKmsBuilder}; +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +use stack_kms::{FallbackKeyProvider, KeyProvider, KeyProviderError, ProfileStore}; +use uuid::Uuid; +use vitaminc_aead::{ + Cipher, CipherText, Context, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, IntoContext, + LocalCipherText, MapAccess, MapCipher, SeqAccess, SeqCipher, Unspecified, +}; +use vitaminc_encrypt::{Aes256Cipher, AesCipherText, Key as AesKey}; +use vitaminc_protected::{Controlled, Protected}; + +use crate::keyset::{KeysetCache, KeysetCipher, KeysetState, Lookup, DEFAULT_NAME_TTL}; +use crate::Descriptor; + +/// The passthrough payload type: type-erased, as for Rust-native vitaminc +/// ciphers. Callers box on the way in and downcast on the way out. +pub type BoxedPassthrough = Box<dyn Any + Send + 'static>; + +/// The recursive ciphertext container produced by [`StackCipher`]: vitaminc's +/// generic [`CipherText`] tree over [`SealedValue`] leaves. Its shape +/// mirrors the encrypted plaintext: a scalar yields `Single`, a `Vec` yields +/// `Sequence` (or `EmptySequence`), a `HashMap` or struct yields `Map` (or +/// `EmptyMap`). Map keys are stored in the clear but bound into their value's +/// AAD. +pub type StackCipherText = CipherText<SealedValue, BoxedPassthrough>; + +/// Errors from sealing or opening a [`StackCipherText`]. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// A ZeroKMS data-key generate/retrieve call failed. + #[error("ZeroKMS data-key operation failed: {0}")] + Kms(#[from] stack_kms::Error), + /// AEAD sealing/opening failed, or the ciphertext shape did not match the + /// requested type. On decrypt this is the expected outcome for a wrong key, + /// wrong AAD, or tampered ciphertext. + #[error("AEAD operation failed (wrong key, AAD mismatch, or malformed ciphertext)")] + Aead, + /// ZeroKMS returned a different number of keys than were requested. + #[error("expected {expected} data keys from ZeroKMS but received {received}")] + KeyCountMismatch { expected: usize, received: usize }, + /// A context rendered to a descriptor longer than ZeroKMS can bind + /// ([`Descriptor::MAX_LEN`]). Raised before any request is sent, so no + /// key is minted or retrieved for the batch. + #[error( + "context renders to a {len}-byte ZeroKMS descriptor; the limit is {} bytes", + Descriptor::MAX_LEN + )] + DescriptorTooLong { len: usize }, + /// Building a ZeroKMS client from the environment failed: credentials or + /// client key missing or malformed. + /// + /// Boxed rather than naming `stack_kms::StackKmsBuilderError` directly: + /// that type only exists with `http`, and a variant whose presence tracks + /// a feature is not additive — feature unification elsewhere in the graph + /// would then change this enum's shape under a downstream match. + #[error("could not build a ZeroKMS client from the environment: {0}")] + Config(#[source] Box<dyn std::error::Error + Send + Sync + 'static>), + /// An index term failed to derive. + #[error(transparent)] + Term(#[from] crate::sem::TermError), + /// A third-party [`EncryptFrom`](crate::target::EncryptFrom) / + /// [`DecryptInto`](crate::target::DecryptInto) implementation failed + /// for a reason of its own. + #[error(transparent)] + Other(Box<dyn std::error::Error + Send + Sync + 'static>), + /// A [`transcode::Visitor`](crate::target::transcode::Visitor) was handed + /// an encrypted output shape its destination does not accept: a scalar + /// destination offered a sequence, say. Raised by the visitor's default + /// methods, so a destination only has to describe the shapes it stores. + /// Never a data error: the output was produced correctly, the + /// destination just has nowhere to put it. + #[error("destination does not support this encrypted output shape")] + UnsupportedShape, + /// A record carrying its context in storage (`#[stash(context_field)]`) + /// was opened with an [`ExpectedContext`](crate::target::ExpectedContext) + /// naming a different one. Refused before any key is retrieved; the + /// descriptor is the stored context's, rendered as ZeroKMS would log it. + #[error("stored context {stored} does not match the expected context")] + ContextMismatch { stored: Descriptor }, + /// A [`Pending`](crate::target::Pending) fulfilment's requests and + /// responses did not line up: it drew more responses — or a different + /// kind — than its requests asked for, or left some of them unconsumed. + /// Always a composition bug in an `EncryptFrom`/`DecryptInto` + /// implementation, never a data error. + #[error("a pending fulfilment's responses did not match its requests")] + ResponseShape, + /// [`Pending`](crate::target::Pending)s scoped to different keysets were + /// merged (`zip` / `all`): one built through a [`KeysetCipher`] for one + /// keyset, the other for another. A row belongs to one tenant; an + /// assembly that spans two is a composition bug, caught before any + /// I/O. (Opening leaves from several keysets in one batch is allowed — + /// through the [`StackCipher`], which is scoped to none.) + /// + /// The keyset is the *whole* merge rule: two pendings built through two + /// different [`StackCipher`] values merge freely as long as they agree + /// on a keyset, because a keyset id is global and a cipher only holds a + /// keyset ZeroKMS resolved for its client. (Before the multi-keyset + /// `StackCipher` there was a `CipherMismatch` variant here, raised on + /// pointer equality of the two ciphers; it tested object identity + /// rather than client identity, and so refused two ciphers over the + /// same client and the same keyset.) + #[error("merged pendings were scoped to different keysets ({left} and {right})")] + KeysetMismatch { left: Uuid, right: Uuid }, + /// A leaf sealed under one keyset was handed to a [`KeysetCipher`] for + /// another. The handle's keyset is a constraint the caller asked for — + /// a tenant-scoped request handler must not open another tenant's row + /// — so this is refused before any key is retrieved. To open leaves + /// from any keyset, decrypt through the [`StackCipher`]. + #[error("leaf was sealed under keyset {found}, not the handle's keyset {expected}")] + ForeignKeyset { expected: Uuid, found: Uuid }, + /// A data key was requested through a [`StackCipher`] rather than a + /// [`KeysetCipher`]: a [`Request::generate_data_key`] needs a keyset + /// to mint under, and only a keyset-scoped pending has one. Always a + /// composition bug in a hand-written `EncryptFrom`, caught before any + /// I/O. + /// + /// [`Request::generate_data_key`]: crate::target::Request::generate_data_key + #[error("a data key was requested with no keyset to mint it under")] + NoKeyset, + /// A [`DecryptField`](crate::target::DecryptField) implementation + /// declared its type [`DECRYPTABLE`](crate::target::Decryptable::DECRYPTABLE) + /// but passed the field over. Always a bug in a third-party + /// `DecryptField`, never a data error. + #[error("a field declared decryptable was not opened by its DecryptField implementation")] + NotOpened, +} + +#[cfg(feature = "http")] +impl From<stack_kms::StackKmsBuilderError> for Error { + fn from(error: stack_kms::StackKmsBuilderError) -> Self { + Error::Config(Box::new(error)) + } +} + +impl From<Unspecified> for Error { + fn from(_: Unspecified) -> Self { + Error::Aead + } +} + +/// The CipherStash cipher, scoped to one client: a ZeroKMS client (a +/// [`DataKeySource`] — production: [`stack_kms::StackKms`]; tests: +/// `stack_kms::FakeDataKeySource`) and the keysets that client uses. +/// +/// Per-leaf keying is deliberate: every value access requires its own data-key +/// retrieval, so individual value accesses are visible (and auditable) as +/// ZeroKMS key-retrieval events. +/// +/// # Keysets +/// +/// Sealing values, sealing records and deriving index terms all happen +/// under a keyset, and a client may use many — one per tenant, say. So +/// those operations bind to a [`KeysetCipher`], the cipher scoped to one +/// keyset: [`default_keyset`](Self::default_keyset) for the client's +/// default — the keyset a ZeroKMS administrator set for this client — +/// [`keyset`](Self::keyset) for any other, by id or by name. Keysets load lazily, through a bounded +/// least-recently-used cache: the first selection of a keyset is one +/// ZeroKMS round trip (its index key, which +/// [Searchable Encrypted Metadata](crate::sem) terms are derived from), +/// and every later one is a lookup. A backend that cannot supply an index +/// key is not a Stack Encrypt backend; plain AEAD with no indexing is what +/// `vitaminc` alone provides. +/// +/// Decrypting is not keyset-scoped: a sealed leaf carries the id of the +/// keyset it was sealed under, and retrieving its data key needs nothing +/// more than that and the client. So [`decrypt`](Self::decrypt) lives here +/// and opens leaves from any keyset the client is authorised for, in one +/// batch. The same method on a [`KeysetCipher`] adds a constraint: it +/// refuses a leaf from any other keyset before any key is retrieved. +/// +/// # Construction +/// +// `new()` builds the ZeroKMS client from the environment, so it exists only +// with `http`; the builder path below works in either shape. +#[cfg_attr( + feature = "http", + doc = r#"[`new`](Self::new) is the default path — a ZeroKMS client from the +environment, on that client's default keyset: + +```no_run +# async fn example() -> Result<(), stack_encrypt::Error> { +use stack_encrypt::StackCipher; + +let cipher = StackCipher::new().await?; +# Ok(()) +# } +``` + +Override with [`builder`](Self::builder) — a different keyset, or a +different data-key source entirely:"# +)] +#[cfg_attr( + not(feature = "http"), + doc = "Build with [`builder`](Self::builder), over an explicit data-key source:" +)] +/// +/// ``` +/// # async fn example() -> Result<(), stack_encrypt::Error> { +/// use stack_encrypt::StackCipher; +/// use stack_kms::FakeDataKeySource; +/// +/// let cipher = StackCipher::builder() +/// .kms(FakeDataKeySource::new()) +/// .init() +/// .await?; +/// # Ok(()) +/// # } +/// # tokio_test_block_on(example()).unwrap(); +/// # fn tokio_test_block_on<F: std::future::Future>(f: F) -> F::Output { +/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(f) +/// # } +/// ``` +/// +/// Construction is async because it resolves the client's default keyset +/// and loads its index key — one ZeroKMS round-trip, paid once, so a +/// misconfigured client fails here rather than on first use. +pub struct StackCipher<K> { + kms: K, + /// The client's default keyset, loaded eagerly by `init` and never + /// evicted. Not the caller's to choose — see + /// [`default_keyset`](Self::default_keyset). + default: Arc<KeysetState>, + /// Every other keyset this cipher has selected, least recently used + /// first out. See [`keyset`](Self::keyset). + keysets: Mutex<KeysetCache>, +} + +#[cfg(feature = "http")] +impl StackCipher<StackKms<stack_auth::AutoStrategy>> { + /// Build a cipher over a ZeroKMS client configured from the environment, + /// on that client's default keyset. + /// + /// Equivalent to `StackCipher::builder().init()`. For a different keyset + /// or a different data-key source, use [`builder`](StackCipher::builder). + pub async fn new() -> Result<Self, Error> { + StackCipher::builder().init().await + } +} + +impl StackCipher<FromEnv> { + /// Start building a cipher: pick a keyset, or supply a data-key source + /// other than the environment's ZeroKMS client. + /// + /// A convenience alias for [`StackCipherBuilder::new`], which is the + /// canonical entry point. This one is anchored on `StackCipher<FromEnv>` + /// purely so `StackCipher::builder()` names a single concrete `K` and + /// resolves without a type annotation; `StackCipher<FromEnv>` is never + /// constructed. + pub fn builder() -> StackCipherBuilder { + StackCipherBuilder::new() + } +} + +/// Opaque: the default keyset's identity and the data-key source's type +/// name, and nothing else. A cipher reaches the whole keyset cache — every +/// loaded keyset's index-key PRF — and, through its backend, the client key +/// and access token; none of that is printable, and a `Debug` that walked +/// the cache would also take its lock. +impl<K> std::fmt::Debug for StackCipher<K> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StackCipher") + .field("default_keyset_id", &self.default.id) + .field("default_keyset_name", &self.default.name) + .field("kms", &std::any::type_name::<K>()) + .finish_non_exhaustive() + } +} + +impl<K> StackCipher<K> { + /// The cipher bound to the client's default keyset: the one a ZeroKMS + /// administrator set for this client, which is what naming no keyset + /// resolves to. Loaded at `init`, so this never touches ZeroKMS. + /// + /// Always that keyset, whatever else the cipher has selected — the + /// default is the workspace's statement about this client, not a + /// preference a caller can override. To work under another keyset, + /// select it with [`keyset`](Self::keyset). + pub fn default_keyset(&self) -> KeysetCipher<'_, K> { + KeysetCipher::new(self, Arc::clone(&self.default)) + } + + /// The underlying data-key source. + pub fn kms(&self) -> &K { + &self.kms + } + + fn keysets(&self) -> std::sync::MutexGuard<'_, KeysetCache> { + // The cache holds no invariant a panic mid-update could break (an + // insert is two map writes, and a stale name entry only points at + // a still-valid state), so a poisoned lock is recovered, not + // propagated. + self.keysets.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl<K: IndexKeySource> StackCipher<K> { + /// The cipher bound to a keyset, by id or by name. + /// + /// The one async point of keyset selection: a keyset this cipher has + /// not seen (or has evicted) is loaded from ZeroKMS here — its id + /// resolved and its index key fetched — and cached; every later + /// selection is a lookup. The returned handle keeps that keyset loaded + /// for as long as it is held, so a request handler that selects its + /// tenant's keyset once never pays again within the request. + /// + /// ``` + /// # async fn example() -> Result<(), stack_encrypt::Error> { + /// use stack_encrypt::{nonempty, StackCipher}; + /// use stack_kms::{FakeDataKeySource, IdentifiedBy}; + /// + /// let cipher = StackCipher::builder() + /// .kms(FakeDataKeySource::new()) + /// .init() + /// .await?; + /// let tenant = cipher.keyset(IdentifiedBy::Name("acme".to_string().into())).await?; + /// let sealed = tenant.encrypt("hello".to_string(), nonempty!("greeting")).await?; + /// # Ok(()) + /// # } + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); + /// ``` + /// + /// A keyset ZeroKMS does not know, or has disabled, is + /// [`Error::Kms`]. The name-to-id resolution is ZeroKMS's: a keyset + /// selected by name reports the resolved id from + /// [`KeysetCipher::keyset_id`], and a name the cipher resolved earlier + /// is trusted for a bounded window + /// ([`keyset_name_ttl`](StackCipherBuilder::keyset_name_ttl)) before it + /// is asked again — ZeroKMS allows renames, and a running process + /// notices one within that window. Selecting by id never asks twice. + pub async fn keyset( + &self, + keyset: impl Into<IdentifiedBy>, + ) -> Result<KeysetCipher<'_, K>, Error> { + let keyset = keyset.into(); + let resolution = match self.keysets().get(&keyset) { + Lookup::Hit(state) => return Ok(KeysetCipher::new(self, state)), + // A name past its window: the keyset is still loaded, but + // whether the name still means it is ZeroKMS's to say. + Lookup::Stale(resolution) | Lookup::Miss(resolution) => resolution, + }; + // Loaded outside the lock: a round trip must not hold up every other + // selection. Two selections racing on the same miss load twice; the + // cache keeps both keysets by id, and the name follows the later + // lookup whichever answer lands first — and so does this caller, + // who is handed the answer that won, not the one that lost. + let asked_name = match &keyset { + IdentifiedBy::Name(name) => Some(name.to_string()), + IdentifiedBy::Uuid(_) => None, + }; + let state = match load_keyset(&self.kms, keyset).await { + Ok(state) => state, + Err(error) => { + // ZeroKMS's own answer that no keyset has this name is an + // answer about the name, and the cache orders it like one: + // the binding an earlier lookup made goes, and an earlier + // positive answer still in flight cannot bind the name after + // it. A lookup that got no answer (transport, auth) says + // nothing about the name and leaves the cache as it was. + if let (Some(name), true) = (&asked_name, is_keyset_not_found(&error)) { + self.keysets().forget(name, resolution); + } + return Err(error); + } + }; + let state = self.keysets().insert(state, resolution); + Ok(KeysetCipher::new(self, state)) + } +} + +/// Resolve a keyset at ZeroKMS and build the state the cipher holds for it: +/// its resolved id, the name it was selected by (a selection by id has +/// none), and the PRF keyed by its index key. The one round trip a keyset +/// costs, shared by eager loading at +/// [`init`](StackCipherBuilder::init) and lazy loading in +/// [`StackCipher::keyset`] so both hold a keyset in exactly the same shape. +/// ZeroKMS answered a load with "no such keyset" — as opposed to not +/// answering at all. +fn is_keyset_not_found(error: &Error) -> bool { + matches!( + error, + Error::Kms(stack_kms::Error::LoadKeyset( + stack_kms::LoadKeysetError::KeysetNotFound(_) + )) + ) +} + +async fn load_keyset<K: IndexKeySource>( + kms: &K, + keyset: IdentifiedBy, +) -> Result<Arc<KeysetState>, Error> { + let name = match &keyset { + IdentifiedBy::Name(name) => Some(name.to_string()), + IdentifiedBy::Uuid(_) => None, + }; + let (id, index_key) = kms.load_index_key(Some(keyset)).await?; + Ok(Arc::new(KeysetState { + id, + name, + prf: hmac_prf_from_index_key(&index_key), + })) +} + +/// The client's own default keyset — the one a ZeroKMS administrator set for +/// this client — asked for by naming nothing. Loaded once, by +/// [`init`](StackCipherBuilder::init); it is not the caller's to choose, so +/// there is no id or name to carry. +async fn load_default_keyset<K: IndexKeySource>(kms: &K) -> Result<Arc<KeysetState>, Error> { + let (id, index_key) = kms.load_index_key(None).await?; + Ok(Arc::new(KeysetState { + id, + name: None, + prf: hmac_prf_from_index_key(&index_key), + })) +} + +/// The state of a [`StackCipherBuilder`] that has not been given a data-key +/// source: [`init`](StackCipherBuilder::init) will build a ZeroKMS client from +/// the environment (and, on native targets, the CLI's profile directory). +pub struct FromEnv; + +/// The client key, looked up the way [`stack_auth::AutoStrategy`] looks up the +/// access token: `CS_CLIENT_ID` / `CS_CLIENT_KEY` first, then the current +/// workspace's `secretkey.json` in the profile directory. A profile directory +/// that cannot be resolved is not an error here — env-only setups (CI) have +/// none — it just leaves the environment as the only source. +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +fn client_key_provider() -> FallbackKeyProvider<EnvKeyProvider, ProfileClientKey> { + FallbackKeyProvider::new( + EnvKeyProvider, + ProfileClientKey(ProfileStore::resolve(None).ok()), + ) +} + +/// wasm32 has no filesystem, so no profile: the environment is the only source. +#[cfg(all(feature = "http", target_arch = "wasm32"))] +fn client_key_provider() -> EnvKeyProvider { + EnvKeyProvider +} + +/// [`ProfileStore`] as a [`KeyProvider`], tolerating an unresolvable profile +/// directory so the "not configured" message can say what to do about it +/// rather than only that `CS_CLIENT_ID` is unset. +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +struct ProfileClientKey(Option<ProfileStore>); + +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] +impl KeyProvider for ProfileClientKey { + async fn client_key(&self) -> Result<stack_kms::ClientKey, KeyProviderError> { + match &self.0 { + Some(store) => store.client_key().await, + None => Err(KeyProviderError::NotConfigured( + "no client key: set CS_CLIENT_ID / CS_CLIENT_KEY, or run `npx stash auth login`" + .into(), + )), + } + } +} + +/// Builder for a [`StackCipher`]. Start with [`StackCipherBuilder::new`] (or +/// its alias [`StackCipher::builder`]). +pub struct StackCipherBuilder<K = FromEnv> { + kms: K, + cache_size: NonZeroUsize, + name_ttl: Duration, +} + +impl StackCipherBuilder<FromEnv> { + /// Start building a cipher: pick a keyset, or supply a data-key source + /// other than the environment's ZeroKMS client. + /// + /// The `K = FromEnv` type default makes this resolve without a type + /// annotation whether or not the `http` feature is on. + pub fn new() -> Self { + Self { + kms: FromEnv, + cache_size: KeysetCache::DEFAULT_CAPACITY, + name_ttl: DEFAULT_NAME_TTL, + } + } +} + +impl Default for StackCipherBuilder<FromEnv> { + fn default() -> Self { + Self::new() + } +} + +impl<K> StackCipherBuilder<K> { + /// How many keysets beyond the default the cipher keeps loaded + /// (default 1024). A process serving more tenants than this reloads a + /// keyset's index key from ZeroKMS when it comes back into use; nothing + /// stored depends on the cache, so the bound only trades memory for + /// round trips. See [`StackCipher::keyset`]. + pub fn keyset_cache_size(mut self, size: NonZeroUsize) -> Self { + self.cache_size = size; + self + } + + /// How long a keyset selected by name is trusted to still be the keyset + /// that name resolved to (default five minutes, [`DEFAULT_NAME_TTL`]). + /// ZeroKMS allows a keyset to be renamed; within the window a rename is + /// invisible to a running process, after it the next selection by that + /// name asks ZeroKMS again. `Duration::ZERO` makes every selection by + /// name a round trip; selection by id is never affected. See + /// [`StackCipher::keyset`]. + /// + /// [`DEFAULT_NAME_TTL`]: crate::keyset::DEFAULT_NAME_TTL + pub fn keyset_name_ttl(mut self, ttl: Duration) -> Self { + self.name_ttl = ttl; + self + } +} + +impl StackCipherBuilder<FromEnv> { + /// Use an explicit data-key source rather than building a ZeroKMS client + /// from the environment. + /// + /// This is the seam for a custom authentication strategy: build a + /// [`stack_kms::StackKms`] — with `stack_kms::StackKmsBuilder`, + /// or over the host's own transport — and hand it over. It is also how + /// tests inject `stack_kms::FakeDataKeySource`. + pub fn kms<K>(self, kms: K) -> StackCipherBuilder<K> { + StackCipherBuilder { + kms, + cache_size: self.cache_size, + name_ttl: self.name_ttl, + } + } + + /// Build a ZeroKMS client from the environment, then resolve the keyset + /// and load its index key. + /// + /// Credentials come from the same two places for both halves of the + /// client — the access token and the client key: + /// + /// 1. the environment (`CS_CLIENT_ACCESS_KEY` + `CS_WORKSPACE_CRN`; + /// `CS_CLIENT_ID` + `CS_CLIENT_KEY`), then + /// 2. the current workspace in the CLI's profile directory + /// (`auth.json`; `secretkey.json`), which `npx stash auth login` + /// writes. + /// + /// So on a developer machine, logging in with the CLI is sufficient; in + /// CI, the four variables are. + #[cfg(feature = "http")] + pub async fn init(self) -> Result<StackCipher<StackKms<stack_auth::AutoStrategy>>, Error> { + let kms = StackKmsBuilder::auto()? + .with_key_provider(client_key_provider()) + .build() + .await?; + self.kms(kms).init().await + } +} + +impl<K: DataKeySource + IndexKeySource> StackCipherBuilder<K> { + /// Resolve the default keyset and load its index key, producing a + /// cipher whose [`default_keyset`](StackCipher::default_keyset) can both + /// seal values and derive index terms. The one round trip a cipher + /// always pays; every other keyset loads on first selection. + pub async fn init(self) -> Result<StackCipher<K>, Error> { + let default = load_default_keyset(&self.kms).await?; + Ok(StackCipher { + kms: self.kms, + keysets: Mutex::new(KeysetCache::new( + self.cache_size, + self.name_ttl, + Arc::clone(&default), + )), + default, + }) + } +} + +/// Build the local HMAC-SHA256 PRF from a per-keyset +/// [`IndexKey`](stack_kms::IndexKey), wiping the intermediate stack copy of the +/// raw key bytes. +fn hmac_prf_from_index_key(index_key: &stack_kms::IndexKey) -> vitaminc_hmac::HmacSha256Prf { + use zeroize::Zeroize; + + // `[u8; 32]` is `Copy`: the move into `Protected` leaves this stack copy + // behind, so wipe it before returning. + let mut key = *index_key.key(); + let prf = <vitaminc_hmac::HmacSha256Prf as vitaminc_prf::PrfKeyInit>::new(Protected::new(key)); + key.zeroize(); + prf +} + +impl<K: DataKeySource> KeysetCipher<'_, K> { + /// Encrypt a value, binding `aad`, and seal it against fresh ZeroKMS data + /// keys in a single batched `generate_keys` call under this keyset. + /// Every key is requested under the [`Descriptor`] of `aad`. + pub async fn encrypt<'a, T, A>(&self, value: T, aad: A) -> Result<StackCipherText, Error> + where + T: Encrypt, + A: IntoContext<'a>, + { + let context = aad.into_context(); + let pending = value.encrypt_with_aad(self, context.clone().into_aad())?; + pending.seal(self, context).await + } + + /// [`StackCipher::decrypt`], constrained to this keyset: a leaf sealed + /// under any other is [`Error::ForeignKeyset`], refused before any key + /// is retrieved. + pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result<T, Error> + where + T: Decrypt<'static> + 'static, + A: IntoContext<'a>, + { + decrypt_through(self, ciphertext, aad).await + } +} + +/// Retrieve every leaf's data key under `aad`'s descriptor and bind them +/// onto the ciphertext, for either scope. The work is the same on both — +/// one descriptor, one pending, one settle — and the scope is the whole +/// difference: a [`KeysetCipher`] constrains the leaves to its keyset, a +/// [`StackCipher`] constrains nothing. +/// +/// Deliberately private. The returned [`StackDecipher`] is driven with an +/// AAD supplied per call, so exposing this would let a caller retrieve keys +/// under one context and authenticate the ciphertext under an unrelated +/// one. Every leaf's AAD is a derivation of the descriptor its key was +/// minted under — `for_sequence_element`, `for_map_entry`, `for_leaf` — +/// and that derivation is the library's to compute, never the caller's to +/// supply. [`decrypt_through`] is the only way in, and it passes one `aad` +/// to both halves. +async fn decipher_through<'s, 'a, K: DataKeySource + 's>( + scope: impl crate::target::CipherScope<'s, K>, + ciphertext: StackCipherText, + aad: impl IntoContext<'a>, +) -> Result<StackDecipher, Error> { + crate::target::decipher_pending(scope, ciphertext, Descriptor::of(aad)) + .settle() + .await +} + +/// `decrypt`, for either scope: [`decipher_through`], then the value's own +/// [`Decrypt`] drive under the same `aad`. +async fn decrypt_through<'s, 'a, T, K: DataKeySource + 's>( + scope: impl crate::target::CipherScope<'s, K>, + ciphertext: StackCipherText, + aad: impl IntoContext<'a>, +) -> Result<T, Error> +where + T: Decrypt<'static> + 'static, +{ + let context = aad.into_context(); + let decipher = decipher_through(scope, ciphertext, context.clone()).await?; + T::decrypt_with_aad(decipher, context.into_aad()).map_err(Error::from) +} + +impl<K: DataKeySource> StackCipher<K> { + /// Decrypt a [`StackCipherText`] into `T`, authenticating against `aad`. + /// + /// One batched `retrieve_keys` call per keyset the leaves were sealed + /// under, every key under the [`Descriptor`] of `aad`, then `T`'s + /// [`Decrypt`] impl drives the resulting [`StackDecipher`] with the + /// *same* `aad` — exactly as `Aes256Cipher::decrypt_with_aad` drives + /// `AesDecipher`. One context in, and every leaf's AAD derived from it; + /// there is no form of this call that takes two. + /// + /// Not keyset-scoped: each leaf carries the id of the keyset it was + /// sealed under, and this opens leaves from any keyset the client is + /// authorised for. To insist on one keyset, decrypt through its + /// [`KeysetCipher`] instead. + /// + /// # Fan-out + /// + /// The retrieve calls are one per *distinct keyset* among the leaves, + /// issued in sequence, and the keyset ids come from the ciphertext — + /// so their number is the input's to decide, up to the keysets this + /// client can retrieve from (ZeroKMS refuses a retrieve whose tag it + /// did not mint, and the first refusal ends the batch). A ciphertext + /// assembled from many tenants' leaves costs a round trip per tenant to + /// open here, whoever assembled it. A service opening rows it does not + /// trust — one tenant's data at a time — should hold that tenant's + /// [`KeysetCipher`], whose decrypt is one round trip at most and refuses + /// a foreign leaf before any. + pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result<T, Error> + where + T: Decrypt<'static> + 'static, + A: IntoContext<'a>, + { + decrypt_through(self, ciphertext, aad).await + } +} + +/// A single sealed leaf: the ZeroKMS metadata needed to retrieve its data key +/// (`iv`, `tag`) plus the vitaminc `LocalCipherText` sealed under that key. +/// +/// This is the only byte-format commitment the crate makes for *ciphertext* — +/// the container tree ([`StackCipherText`]) has no canonical encoding, so +/// callers that persist or transmit ciphertext serialise leaves and rebuild +/// the tree around them. Index terms are a separate commitment with their own +/// frozen encodings (see the +/// [index-term encodings](crate::sem#byte-encodings)). +/// +/// # Frozen byte encoding +/// +/// [`to_bytes`](Self::to_bytes) / [`from_bytes`](Self::from_bytes) are the +/// canonical encoding — the one storage format every consumer (this crate, +/// the language bindings, anything reading a database column) agrees on. +/// The v1 layout: +/// +/// | offset | field | size | value | +/// |-----------------|--------------------|-----------------|-------| +/// | 0 | envelope version | 1 | [`FORMAT_VERSION`](Self::FORMAT_VERSION) (`0x01`) | +/// | 1 | keyset id | 16 | the ZeroKMS keyset the data key was minted under, raw UUID bytes | +/// | 17 | ZeroKMS `iv` | 16 | identifies the data key for retrieval | +/// | 33 | `tag_len` | 2 | length of `tag`, `u16` little-endian | +/// | 35 | ZeroKMS key `tag` | `tag_len` | required to retrieve the key | +/// | 35 + `tag_len` | local ciphertext | rest of buffer | the vitaminc `LocalCipherText` | +/// +/// The keyset id is what lets a leaf be opened without the caller saying +/// which keyset it belongs to: retrieving the data key needs the keyset, +/// and the leaf is self-describing so that a leaf lifted from a tree — what +/// a database column holds — is too. +/// +/// The local ciphertext is itself a framed value — vitaminc's leaf wire +/// format, versioned and owned by vitaminc — so the full stored byte string +/// nests two framings, each led by its own version byte: +/// +/// ```text +/// ┌─ envelope (stack-encrypt, this table) ──────────────────────────────────────────────┐ +/// │ version ‖ keyset_id ‖ iv ‖ tag_len ‖ tag ‖ ┌─ local ciphertext (vitaminc) ───────────┐ │ +/// │ 0x01 │ version ‖ nonce ‖ ciphertext ‖ gcm_tag │ │ +/// │ └─────────────────────────────────────────┘ │ +/// └─────────────────────────────────────────────────────────────────────────────────────┘ +/// ``` +/// +/// Both version bytes and the keyset id are authenticated under the one GCM +/// tag, each bound by the layer that owns its framing: the envelope version +/// and the keyset id through this crate's leaf-AAD derivation, +/// `PAE("stack-encrypt/leaf", version, keyset_id, derived_aad, tag)`, and +/// the inner version through vitaminc's `Context::for_leaf`, applied inside +/// `Aes256Cipher` to the AAD this crate hands it. Relabel either version +/// byte, or re-point the leaf at another keyset, in storage and the leaf +/// fails authentication rather than parsing under the wrong rules. Parsing +/// is structural only — nothing about a decoded leaf is trusted until it +/// decrypts. +/// +/// The `serde` `Serialize`/`Deserialize` derives and +/// [`into_parts`](Self::into_parts) / [`from_parts`](Self::from_parts) +/// remain for callers that manage their own storage format; they carry the +/// same fields, but their wire form is the serialiser's, not a commitment +/// of this crate. +#[derive(Debug, Serialize, Deserialize)] +#[serde(try_from = "SealedValueRepr")] +pub struct SealedValue { + /// The keyset the data key was minted under; retrieval names it. + keyset_id: Uuid, + /// ZeroKMS IV: identifies the data key for retrieval. + iv: stack_kms::Iv, + /// ZeroKMS key tag: required to retrieve the key, and bound into the + /// leaf's AAD so the ciphertext is tied to its data key. + tag: Vec<u8>, + /// The leaf sealed by [`vitaminc_encrypt::Aes256Cipher`] under the data + /// key: `version ‖ nonce ‖ ciphertext ‖ gcm_tag`. + ciphertext: LocalCipherText, +} + +/// A [`SealedValue`] byte encoding failed to encode or decode. Purely +/// structural — a leaf that *decodes* has proven nothing about integrity +/// (that is the AEAD open's job); a leaf that fails here was never a valid +/// v1 encoding at all. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum LeafBytesError { + /// The leading version byte is not one this build knows how to parse. + /// (A version this build *does* know, stamped on bytes sealed under a + /// different version, passes here and fails authentication instead — + /// the version byte is bound into the leaf AAD.) + #[error("unknown sealed-leaf format version {0}")] + UnknownVersion(u8), + /// The buffer ends before the fixed-width fields, or before the key tag + /// the `tag_len` field promises. + #[error("sealed-leaf bytes are truncated")] + Truncated, + /// The key tag does not fit the format's `u16` length field. Every + /// construction site rejects an oversized tag — [`SealedValue::from_parts`] + /// and `serde` deserialisation with this error, and the seal path (where a + /// custom [`DataKeySource`] could return one; real ZeroKMS tags are tens + /// of bytes) by failing the encrypt — so a live `SealedValue` always + /// encodes. + #[error("key tag of {0} bytes exceeds the format's u16 length field")] + TagTooLong(usize), +} + +impl SealedValue { + /// The version byte prefixing the frozen byte encoding + /// ([`to_bytes`](Self::to_bytes)). Also bound into every leaf's AAD (the + /// private `leaf_aad` derivation): bumping it re-keys authentication, so + /// old leaves can never be relabelled as the new version (nor new as + /// old). + pub const FORMAT_VERSION: u8 = 1; + + /// Encode into the frozen v1 byte layout — see the type-level docs for + /// the format. The inverse of [`from_bytes`](Self::from_bytes). + /// + /// Infallible: every way of building a `SealedValue` rejects a tag too + /// long for the `u16` length field ([`LeafBytesError::TagTooLong`]), so a + /// value that exists always encodes. + pub fn to_bytes(&self) -> Vec<u8> { + // Exact by the `tag_fits_length_field` check every construction site + // applies — `from_parts`, serde deserialisation, and the seal path + // (which guards against a `DataKeySource` returning an oversized tag). + // The saturating fallback is unreachable, and asserted so in tests. + let tag_len = u16::try_from(self.tag.len()).unwrap_or(u16::MAX); + debug_assert_eq!(usize::from(tag_len), self.tag.len()); + let ciphertext = self.ciphertext.as_ref(); + let mut out = + Vec::with_capacity(1 + 16 + self.iv.len() + 2 + self.tag.len() + ciphertext.len()); + out.push(Self::FORMAT_VERSION); + out.extend_from_slice(self.keyset_id.as_bytes()); + out.extend_from_slice(&self.iv); + out.extend_from_slice(&tag_len.to_le_bytes()); + out.extend_from_slice(&self.tag); + out.extend_from_slice(ciphertext); + out + } + + /// Decode the frozen v1 byte layout — the inverse of + /// [`to_bytes`](Self::to_bytes). Structural only: a decoded leaf is + /// untrusted bytes until it decrypts. + pub fn from_bytes(bytes: &[u8]) -> Result<Self, LeafBytesError> { + const IV_LEN: usize = 16; + + let (&version, rest) = bytes.split_first().ok_or(LeafBytesError::Truncated)?; + if version != Self::FORMAT_VERSION { + return Err(LeafBytesError::UnknownVersion(version)); + } + if rest.len() < 16 + IV_LEN + 2 { + return Err(LeafBytesError::Truncated); + } + let (keyset_bytes, rest) = rest.split_at(16); + let keyset_id = Uuid::from_slice(keyset_bytes).map_err(|_| LeafBytesError::Truncated)?; + let (iv_bytes, rest) = rest.split_at(IV_LEN); + let mut iv: stack_kms::Iv = [0; IV_LEN]; + iv.copy_from_slice(iv_bytes); + let (tag_len_bytes, rest) = rest.split_at(2); + let tag_len = usize::from(u16::from_le_bytes([tag_len_bytes[0], tag_len_bytes[1]])); + if rest.len() < tag_len { + return Err(LeafBytesError::Truncated); + } + let (tag, ciphertext) = rest.split_at(tag_len); + Ok(Self { + keyset_id, + iv, + tag: tag.to_vec(), + ciphertext: LocalCipherText::from(ciphertext.to_vec()), + }) + } + + /// The one invariant that makes [`to_bytes`](Self::to_bytes) infallible: + /// the key tag must fit the format's `u16` length field. + fn tag_fits_length_field(tag: &[u8]) -> Result<(), LeafBytesError> { + if tag.len() > usize::from(u16::MAX) { + return Err(LeafBytesError::TagTooLong(tag.len())); + } + Ok(()) + } + + /// Rebuild a leaf from its persisted parts — the inverse of + /// [`into_parts`](Self::into_parts). + /// + /// Fails with [`LeafBytesError::TagTooLong`] if `tag` does not fit the + /// byte format's `u16` length field. Structural only: nothing about the + /// parts is trusted until the leaf decrypts. + pub fn from_parts( + keyset_id: Uuid, + iv: stack_kms::Iv, + tag: Vec<u8>, + ciphertext: Vec<u8>, + ) -> Result<Self, LeafBytesError> { + Self::tag_fits_length_field(&tag)?; + Ok(Self { + keyset_id, + iv, + tag, + ciphertext: LocalCipherText::from(ciphertext), + }) + } + + /// Decompose into `(keyset_id, iv, tag, ciphertext)` for persistence. + pub fn into_parts(self) -> (Uuid, stack_kms::Iv, Vec<u8>, Vec<u8>) { + ( + self.keyset_id, + self.iv, + self.tag, + self.ciphertext.into_inner().to_vec(), + ) + } + + /// The keyset this leaf's data key was minted under, and so the one it + /// is retrieved from. Authenticated: a leaf re-pointed at another + /// keyset fails to open. + pub fn keyset_id(&self) -> Uuid { + self.keyset_id + } + + /// The ZeroKMS IV identifying this leaf's data key. + pub fn iv(&self) -> &stack_kms::Iv { + &self.iv + } + + /// The ZeroKMS key tag. + pub fn tag(&self) -> &[u8] { + &self.tag + } + + /// The sealed bytes (`version ‖ nonce ‖ ciphertext ‖ gcm_tag`). + pub fn ciphertext(&self) -> &[u8] { + self.ciphertext.as_ref() + } +} + +impl Clone for SealedValue { + fn clone(&self) -> Self { + Self { + keyset_id: self.keyset_id, + iv: self.iv, + tag: self.tag.clone(), + ciphertext: LocalCipherText::from(self.ciphertext.as_ref().to_vec()), + } + } +} + +/// [`SealedValue::from_bytes`] as a std conversion — the same decoder, for +/// callers who prefer the std trait. +impl TryFrom<&[u8]> for SealedValue { + type Error = LeafBytesError; + + fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> { + Self::from_bytes(bytes) + } +} + +/// Deserialisation shadow for [`SealedValue`]: `serde` bypasses +/// [`SealedValue::from_parts`], so the tag-length invariant +/// [`to_bytes`](SealedValue::to_bytes) relies on is re-checked here. Same +/// field names as the derive, so the wire form is unchanged. +#[derive(Deserialize)] +#[serde(rename = "SealedValue")] +struct SealedValueRepr { + keyset_id: Uuid, + iv: stack_kms::Iv, + tag: Vec<u8>, + ciphertext: LocalCipherText, +} + +impl TryFrom<SealedValueRepr> for SealedValue { + type Error = LeafBytesError; + + fn try_from(repr: SealedValueRepr) -> Result<Self, Self::Error> { + let SealedValueRepr { + keyset_id, + iv, + tag, + ciphertext, + } = repr; + Self::tag_fits_length_field(&tag)?; + Ok(Self { + keyset_id, + iv, + tag, + ciphertext, + }) + } +} + +/// A leaf with its retrieved data key bound alongside. Produced by +/// [`bind_keys`] once the batched `retrieve_keys` call has returned; consumed by +/// [`StackDecipher`], which opens it under whatever AAD the driving +/// [`Decrypt`] impl supplies. +pub(crate) struct KeyedLeaf { + leaf: SealedValue, + key: DataKey, +} + +/// [`StackCipherText`] with a [`DataKey`] zipped onto every keyed leaf. +pub(crate) type KeyedCipherText = CipherText<KeyedLeaf, BoxedPassthrough>; + +/// Zip retrieved keys onto the tree in the same depth-first order the +/// target layer requested them (`retrieve_requests`), so each leaf carries its +/// own key and the subsequent [`Decipher`] drive is free of ordering +/// assumptions. +pub(crate) fn bind_keys( + ciphertext: StackCipherText, + keys: &mut impl Iterator<Item = DataKey>, +) -> Result<KeyedCipherText, Unspecified> { + fn bind( + leaf: SealedValue, + keys: &mut impl Iterator<Item = DataKey>, + ) -> Result<KeyedLeaf, Unspecified> { + let key = keys.next().ok_or(Unspecified)?; + Ok(KeyedLeaf { leaf, key }) + } + + match ciphertext { + CipherText::Single(leaf) => Ok(CipherText::Single(bind(leaf, keys)?)), + CipherText::None(leaf) => Ok(CipherText::None(bind(leaf, keys)?)), + CipherText::EmptySequence(leaf) => Ok(CipherText::EmptySequence(bind(leaf, keys)?)), + CipherText::EmptyMap(leaf) => Ok(CipherText::EmptyMap(bind(leaf, keys)?)), + CipherText::Sequence(items) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(bind_keys(item, keys)?); + } + Ok(CipherText::Sequence(out)) + } + CipherText::Map(entries) => { + let mut out = Vec::with_capacity(entries.len()); + for (k, v) in entries { + out.push((k, bind_keys(v, keys)?)); + } + Ok(CipherText::Map(out)) + } + CipherText::Passthrough(value) => Ok(CipherText::Passthrough(value)), + } +} + +// ============================================================================= +// Pending tree (`Cipher::Ok`) — built synchronously, sealed asynchronously +// ============================================================================= + +/// The intermediate result of driving the [`Cipher`] trait: a tree that holds +/// plaintext (and the AAD each leaf will be sealed against, fully derived) but +/// has done no ZeroKMS I/O. [`seal`](Self::seal) turns it into a +/// [`StackCipherText`]. +pub enum PendingStackCipherText { + /// A scalar awaiting a data key, with its bound (derived) AAD. + Single { + plaintext: Protected<Vec<u8>>, + aad: Context<'static>, + }, + /// A pending sequence with at least one element. + Sequence(Vec<PendingStackCipherText>), + /// A pending empty-sequence marker; `aad` is already the + /// [`Context::for_empty_sequence`] derivation. + EmptySequence { aad: Context<'static> }, + /// A pending map with at least one entry. + Map(Vec<(String, PendingStackCipherText)>), + /// A pending empty-map marker; `aad` is already the [`Context::for_empty_map`] + /// derivation. + EmptyMap { aad: Context<'static> }, + /// A pending authenticated-absent marker; `aad` is already the + /// [`Context::for_none`] derivation. + None { aad: Context<'static> }, + /// A passthrough value (needs no key). + Passthrough(BoxedPassthrough), +} + +impl PendingStackCipherText { + /// Number of leaves that need a ZeroKMS data key (everything but + /// passthrough — markers are sealed leaves too). + pub(crate) fn key_count(&self) -> usize { + match self { + PendingStackCipherText::Single { .. } + | PendingStackCipherText::None { .. } + | PendingStackCipherText::EmptySequence { .. } + | PendingStackCipherText::EmptyMap { .. } => 1, + PendingStackCipherText::Sequence(items) => items.iter().map(Self::key_count).sum(), + PendingStackCipherText::Map(entries) => { + entries.iter().map(|(_, v)| v.key_count()).sum() + } + PendingStackCipherText::Passthrough(_) => 0, + } + } + + /// Generate one data key per keyed leaf (one batched ZeroKMS call — none + /// for a passthrough-only tree), every key under the [`Descriptor`] of + /// `aad`, and seal the whole tree. + /// + /// `aad` is the context the tree was built under — the value passed to + /// `encrypt_with_aad`, in the same shape (see the + /// [descriptor docs](crate::descriptor)). The tree itself only carries + /// the *derived* per-leaf AADs, so the root is named here; nothing can + /// check that the two agree, which is why [`KeysetCipher::encrypt`], which + /// does both steps from one value, is the form to prefer. + /// + /// Settles through the target layer's request carrier + /// ([`seal_pending`](crate::target)), so this and + /// `encrypt_into_with_context` into a `StackCipherText` share one definition of how a tree + /// is sealed and one path to ZeroKMS. + pub async fn seal<'a, K: DataKeySource>( + self, + cipher: &KeysetCipher<'_, K>, + aad: impl IntoContext<'a>, + ) -> Result<StackCipherText, Error> { + self.into_pending(cipher, aad).settle().await + } + + /// Turn this tree into a [`Pending`](crate::target::Pending) request + /// carrier without settling it. + /// + /// [`seal`](Self::seal) is this plus an immediate settle — one ZeroKMS + /// call per tree. `into_pending` exists for callers that hold *several* + /// independently built trees (each from its own [`Encrypt`] drive, e.g. + /// one per record field in a language binding) and want them merged with + /// [`Pending::zip`](crate::target::Pending::zip) / + /// [`Pending::all`](crate::target::Pending::all) so the whole assembly + /// seals in **one** batched `generate_keys` call. Same sealing path + /// either way. + /// + /// **Context contract.** The cipher-directed path deliberately accepts + /// *any* AAD, including none at all (`()`) — it mirrors + /// `Aes256Cipher`, where AAD-less sealing is a legitimate AEAD use, + /// opened symmetrically by [`StackCipher::decrypt`]. But a tree that + /// will be opened through the target layer's + /// [`decrypt_into`](crate::target::DecryptInto) — a per-field record + /// assembly in an FFI front-end, say — is bound by that layer's rule: it + /// opens only under a [`NonEmpty`](crate::NonEmpty) context, so seal + /// under one here (a `NonEmpty<T>` is an [`IntoContext`] like any other, and + /// encodes exactly as `T` does) or the ciphertext can never be read that + /// way. + pub fn into_pending<'c, 'a, K>( + self, + cipher: &'a KeysetCipher<'_, K>, + aad: impl IntoContext<'c>, + ) -> crate::target::Pending<'a, StackCipherText, K> { + crate::target::seal_pending(cipher, self, Descriptor::of(aad)) + } + + /// Recursively seal under `keyset_id`, drawing one key per leaf from + /// `keys` in traversal order. + pub(crate) fn seal_with( + self, + keyset_id: Uuid, + keys: &mut impl Iterator<Item = DataKeyWithTag>, + ) -> Result<StackCipherText, Unspecified> { + // Markers seal an *empty* plaintext so the AEAD tag still binds their + // (already domain-separated) AAD, mirroring `Aes256Cipher`. + fn seal_marker( + aad: Context<'static>, + keyset_id: Uuid, + keys: &mut impl Iterator<Item = DataKeyWithTag>, + ) -> Result<SealedValue, Unspecified> { + let key = keys.next().ok_or(Unspecified)?; + seal_leaf(Protected::new(Vec::new()), &aad, keyset_id, key) + } + + match self { + PendingStackCipherText::Single { plaintext, aad } => { + let key = keys.next().ok_or(Unspecified)?; + Ok(CipherText::Single(seal_leaf( + plaintext, &aad, keyset_id, key, + )?)) + } + PendingStackCipherText::None { aad } => { + Ok(CipherText::None(seal_marker(aad, keyset_id, keys)?)) + } + PendingStackCipherText::EmptySequence { aad } => Ok(CipherText::EmptySequence( + seal_marker(aad, keyset_id, keys)?, + )), + PendingStackCipherText::EmptyMap { aad } => { + Ok(CipherText::EmptyMap(seal_marker(aad, keyset_id, keys)?)) + } + PendingStackCipherText::Sequence(items) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(item.seal_with(keyset_id, keys)?); + } + Ok(CipherText::Sequence(out)) + } + PendingStackCipherText::Map(entries) => { + let mut out = Vec::with_capacity(entries.len()); + for (k, v) in entries { + out.push((k, v.seal_with(keyset_id, keys)?)); + } + Ok(CipherText::Map(out)) + } + PendingStackCipherText::Passthrough(value) => Ok(CipherText::Passthrough(value)), + } + } +} + +// ============================================================================= +// Leaf crypto +// ============================================================================= + +/// Build the per-leaf vitaminc cipher from a ZeroKMS data key. +/// +/// Each leaf has its own data key, so each leaf gets its own +/// [`Aes256Cipher`] (and with it a fresh random nonce — the ZeroKMS IV is a +/// key identifier only, never reused as a nonce). +fn leaf_cipher(key: &DataKey) -> Result<Aes256Cipher, Unspecified> { + // `Key::from` moves the 32 bytes straight into a `Protected`; the source + // `DataKey` is wiped on its own drop. + Aes256Cipher::new(&AesKey::from(*key.key())) +} + +/// Derives the effective AAD every leaf is sealed against — and opened +/// under — binding the caller's derived AAD, the ZeroKMS key `tag`, the +/// keyset the key was minted under, and the [`SealedValue::FORMAT_VERSION`] +/// byte that prefixes the leaf's frozen byte encoding. +/// +/// The labelled five-piece PAE can never collide with a caller's own +/// composite AAD (a tuple encodes with no leading domain label) or with +/// vitaminc's internal derivations (different labels). Binding the format +/// version and the keyset id under the tag is what makes those bytes in +/// [`SealedValue::to_bytes`] more than parse hints: bytes relabelled with a +/// different version fail verification instead of selecting different +/// parsing and derivation rules — mirroring vitaminc's `Context::for_leaf`, +/// which binds the *inner* [`LocalCipherText`] wire version the same way — +/// and a leaf re-pointed at another keyset fails verification instead of +/// asking that keyset for a key it never minted. +/// +/// The domain label deliberately carries no `/v1` suffix: the version is a +/// *parameter* here, not part of the label. +/// +/// # Breaking change +/// +/// This derivation has changed twice while the crate is `publish = false` +/// (an unlabelled `PAE(aad, tag)` tuple; then a four-piece labelled form +/// without the keyset id), each time without a version bump, because only +/// dev-persisted data existed. A leaf sealed under an earlier form fails +/// authentication in `open_leaf` with a plain AEAD error, indistinguishable +/// from tampering; re-encrypt anything that matters. +fn leaf_aad(aad: &Context<'_>, keyset_id: Uuid, tag: &[u8]) -> Context<'static> { + const LEAF_AAD_DOMAIN: &[u8] = b"stack-encrypt/leaf"; + Context::pae(&[ + LEAF_AAD_DOMAIN, + &[SealedValue::FORMAT_VERSION], + keyset_id.as_bytes(), + aad.as_bytes(), + tag, + ]) +} + +/// Seal one plaintext leaf under a freshly generated data key. +/// +/// The AAD is the [`leaf_aad`] derivation of the caller's (derived) AAD, the +/// keyset the key was minted under, and the key `tag` — `tag` is always +/// bound, so the leaf is cryptographically tied to its ZeroKMS data key. +fn seal_leaf( + plaintext: Protected<Vec<u8>>, + aad: &Context<'_>, + keyset_id: Uuid, + key: DataKeyWithTag, +) -> Result<SealedValue, Unspecified> { + // The `DataKeySource` is caller-supplied, so the key tag is not trusted + // to fit the frozen byte format's `u16` length field: an oversized tag + // must fail here — the last construction site — or `to_bytes` would emit + // a length field that no longer frames the tag and `from_bytes` would + // stop inverting it. + SealedValue::tag_fits_length_field(&key.tag).map_err(|_| Unspecified)?; + let iv = key.key.iv; + let cipher = leaf_cipher(&key.key)?; + match (&cipher).encrypt_bytes_vec(plaintext, leaf_aad(aad, keyset_id, &key.tag))? { + AesCipherText::Single(ciphertext) => Ok(SealedValue { + keyset_id, + iv, + tag: key.tag, + ciphertext, + }), + _ => Err(Unspecified), + } +} + +/// Open one keyed leaf under `aad`, returning the plaintext bytes. +fn open_leaf(keyed: KeyedLeaf, aad: &Context<'_>) -> Result<Protected<Vec<u8>>, Unspecified> { + /// Keeps the recovered bytes inside `Protected` across the visitor + /// boundary (the blanket `Decrypt for Vec<u8>` would unwrap them). + struct ProtectedBytes; + impl<'c> DecipherVisitor<'c> for ProtectedBytes { + type Value = Protected<Vec<u8>>; + fn visit_bytes_vec(self, data: Protected<Vec<u8>>) -> Result<Self::Value, Unspecified> { + Ok(data) + } + } + + let KeyedLeaf { leaf, key } = keyed; + let cipher = leaf_cipher(&key)?; + cipher + .decipher(AesCipherText::Single(leaf.ciphertext)) + .decrypt_bytes(ProtectedBytes, leaf_aad(aad, leaf.keyset_id, &leaf.tag)) +} + +/// Open one marker leaf (absent / empty-sequence / empty-map) and require the +/// sealed plaintext to be empty. Without the emptiness check, a `Single` leaf +/// could be re-tagged as a marker of the same AAD derivation. +fn verify_empty_marker(keyed: KeyedLeaf, aad: &Context<'_>) -> Result<(), Unspecified> { + let plaintext = open_leaf(keyed, aad)?; + if plaintext.risky_ref().is_empty() { + Ok(()) + } else { + Err(Unspecified) + } +} + +// ============================================================================= +// Encrypt side: `Cipher` impl over a `&KeysetCipher` (builds the pending tree) +// ============================================================================= + +impl<'c, 'k, K> Cipher for &'c KeysetCipher<'k, K> { + type Ok = PendingStackCipherText; + type Error = Unspecified; + type Passthrough = BoxedPassthrough; + type SeqCipher = PendingSeqCipher<'c, 'k, K>; + type MapCipher = PendingMapCipher<'c, 'k, K>; + + fn encrypt_bytes_vec<'a, A>( + self, + data: Protected<Vec<u8>>, + aad: A, + ) -> Result<Self::Ok, Self::Error> + where + A: IntoAad<'a>, + { + Ok(PendingStackCipherText::Single { + plaintext: data, + aad: aad.into_aad().into_owned(), + }) + } + + fn encrypt_seq<'a, A>(self, size_hint: Option<usize>, aad: A) -> Self::SeqCipher + where + A: IntoAad<'a>, + { + let aad = aad.into_aad().into_owned(); + PendingSeqCipher { + cipher: self, + items: Vec::with_capacity(size_hint.unwrap_or(0)), + // Both derived once here, then borrowed per element. + element_aad: aad.for_sequence_element(), + aad, + encrypted: false, + } + } + + fn encrypt_map<'a, A>(self, aad: A) -> Self::MapCipher + where + A: IntoAad<'a>, + { + PendingMapCipher { + cipher: self, + entries: Vec::new(), + seen_keys: HashSet::new(), + current_key: None, + aad: aad.into_aad().into_owned(), + encrypted: false, + } + } + + fn encrypt_none<'a, A>(self, aad: A) -> Result<Self::Ok, Self::Error> + where + A: IntoAad<'a>, + { + // Domain-separated so a `Single` leaf sealed under the bare AAD can + // never be re-tagged as an authenticated absence (and vice versa). + Ok(PendingStackCipherText::None { + aad: aad.into_aad().for_none(), + }) + } + + fn passthrough(self, value: Self::Passthrough) -> Result<Self::Ok, Self::Error> { + Ok(PendingStackCipherText::Passthrough(value)) + } + + fn passthrough_boxed( + self, + value: Box<dyn Any + Send + 'static>, + ) -> Result<Self::Ok, Self::Error> { + // This cipher's passthrough type *is* `Box<dyn Any + Send>`, so the + // type-erased box is already the passthrough payload. + self.passthrough(value) + } +} + +/// [`SeqCipher`] driver: accumulates a pending sub-tree per element. Holds the +/// cipher only to re-drive nested [`Encrypt`] values (no I/O happens here). +pub struct PendingSeqCipher<'c, 'k, K> { + cipher: &'c KeysetCipher<'k, K>, + items: Vec<PendingStackCipherText>, + /// The AAD fixed at [`Cipher::encrypt_seq`]; the empty marker is sealed + /// against its `for_empty_sequence` derivation. + aad: Context<'static>, + /// [`Context::for_sequence_element`] of `aad`, derived once and applied to + /// every element. + element_aad: Context<'static>, + /// Whether at least one element went through the authenticated + /// [`encrypt_next`](SeqCipher::encrypt_next) path *and* produced a sealed + /// node — see [`end`](SeqCipher::end). + encrypted: bool, +} + +impl<K> SeqCipher for PendingSeqCipher<'_, '_, K> { + type Ok = PendingStackCipherText; + type Error = Unspecified; + type Passthrough = BoxedPassthrough; + + fn encrypt_next<T>(mut self, data: T) -> Result<Self, Self::Error> + where + T: Encrypt, + { + // Borrow the stored derived AAD — no allocation per element. + let pending = data.encrypt_with_aad(self.cipher, self.element_aad.clone())?; + // A nested `Encrypt` impl may route through the passthrough channel; + // only a genuinely pending-sealed node may satisfy `end`'s + // all-passthrough rejection. + self.encrypted |= !matches!(pending, PendingStackCipherText::Passthrough(_)); + self.items.push(pending); + Ok(self) + } + + fn passthrough_next(mut self, value: Self::Passthrough) -> Result<Self, Self::Error> { + self.items.push(PendingStackCipherText::Passthrough(value)); + Ok(self) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + if self.items.is_empty() { + Ok(PendingStackCipherText::EmptySequence { + aad: self.aad.for_empty_sequence(), + }) + } else if !self.encrypted { + // Every item is a passthrough: nothing in the container would + // authenticate the AAD, so refuse to produce it — mirroring + // `decrypt_tree`'s rejection on open. + Err(Unspecified) + } else { + Ok(PendingStackCipherText::Sequence(self.items)) + } + } +} + +/// [`MapCipher`] driver: keys are stored in the clear; values become pending +/// sub-trees sealed against [`Context::for_map_entry`] of the map AAD and their +/// key. Mirrors `AesMapCipher`'s key/value and duplicate-key contract checks. +pub struct PendingMapCipher<'c, 'k, K> { + cipher: &'c KeysetCipher<'k, K>, + entries: Vec<(String, PendingStackCipherText)>, + /// Duplicate keys are rejected at encrypt time: `decrypt_tree` rejects + /// them outright, so accepting one here would produce a permanently + /// unreadable ciphertext. + seen_keys: HashSet<String>, + current_key: Option<Cow<'static, str>>, + /// The AAD fixed at [`Cipher::encrypt_map`]. + aad: Context<'static>, + /// See [`PendingSeqCipher::encrypted`]. + encrypted: bool, +} + +impl<K> MapCipher for PendingMapCipher<'_, '_, K> { + type Ok = PendingStackCipherText; + type Error = Unspecified; + type Passthrough = BoxedPassthrough; + + fn encrypt_key<S>(mut self, key: S) -> Result<Self, Self::Error> + where + S: Into<Cow<'static, str>>, + { + // A pending key means `encrypt_key` ran twice with no intervening + // `encrypt_value` — fail rather than silently drop the first key. + if self.current_key.is_some() { + return Err(Unspecified); + } + let key = key.into(); + if !self.seen_keys.insert(key.as_ref().to_owned()) { + return Err(Unspecified); + } + self.current_key = Some(key); + Ok(self) + } + + fn encrypt_value<U>(mut self, value: U) -> Result<Self, Self::Error> + where + U: Encrypt, + { + let key = self.current_key.take().ok_or(Unspecified)?; + // Seal against PAE(domain, aad, key) — key and value are inseparable. + // `decrypt_tree` derives the same AAD on open. + let entry_aad = self.aad.for_map_entry(&key); + let pending = value.encrypt_with_aad(self.cipher, entry_aad)?; + self.encrypted |= !matches!(pending, PendingStackCipherText::Passthrough(_)); + self.entries.push((key.into_owned(), pending)); + Ok(self) + } + + fn passthrough_entry<S>(mut self, key: S, value: Self::Passthrough) -> Result<Self, Self::Error> + where + S: Into<Cow<'static, str>>, + { + // Adopting a pending key here would silently drop it — same contract + // violation `encrypt_key` rejects. + if self.current_key.is_some() { + return Err(Unspecified); + } + let key = key.into(); + if !self.seen_keys.insert(key.as_ref().to_owned()) { + return Err(Unspecified); + } + self.entries + .push((key.into_owned(), PendingStackCipherText::Passthrough(value))); + Ok(self) + } + + fn passthrough_entry_boxed<S>( + self, + key: S, + value: Box<dyn Any + Send + 'static>, + ) -> Result<Self, Self::Error> + where + S: Into<Cow<'static, str>>, + { + // This cipher's passthrough type *is* `Box<dyn Any + Send>`, so the + // type-erased box is already the payload — same as `passthrough_boxed`. + self.passthrough_entry(key, value) + } + + fn end(self) -> Result<Self::Ok, Self::Error> { + // Finalising with a pending key would silently drop the entry. + if self.current_key.is_some() { + return Err(Unspecified); + } + if self.entries.is_empty() { + Ok(PendingStackCipherText::EmptyMap { + aad: self.aad.for_empty_map(), + }) + } else if !self.encrypted { + // Every entry is a passthrough — see `PendingSeqCipher::end`. + Err(Unspecified) + } else { + Ok(PendingStackCipherText::Map(self.entries)) + } + } +} + +// Decrypt side: a synchronous `Decipher` over a key-bound ciphertext tree +// ============================================================================= + +/// A [`Decipher`] over a single [`StackCipherText`] whose leaves already +/// carry their retrieved data keys, built inside +/// [`StackCipher::decrypt`] and driven there by the value's [`Decrypt`] +/// impl under the same context the keys were retrieved with. +/// +/// Structurally identical to `vitaminc_encrypt::AesDecipher` — the only +/// difference is where each leaf's key comes from. The AAD is supplied per call +/// by [`Decrypt::decrypt_with_aad`] and refined here exactly as the encrypt side +/// refined it: sequence elements under [`Context::for_sequence_element`], map +/// values under [`Context::for_map_entry`] of their key, markers under their +/// respective derivations with an enforced-empty plaintext. Because the +/// derivation lives in this drive (not in a pre-pass), `Decrypt` impls that +/// transform the AAD themselves (e.g. `vitaminc_aead::Element`) work unchanged. +pub struct StackDecipher { + ciphertext: KeyedCipherText, +} + +impl StackDecipher { + pub(crate) fn over(ciphertext: KeyedCipherText) -> Self { + Self { ciphertext } + } + + /// Typed convenience over [`Decipher::decrypt_passthrough`] for this + /// cipher's [`BoxedPassthrough`] payload type: recovers the payload and + /// downcasts it to `T`, returning [`Unspecified`] if the ciphertext is not + /// a passthrough or the stored type does not match. + pub fn decrypt_passthrough_as<T>(self) -> Result<T, Unspecified> + where + T: Any + Send + 'static, + { + self.decrypt_passthrough()? + .downcast::<T>() + .map(|b| *b) + .map_err(|_| Unspecified) + } +} + +impl<'c> Decipher<'c> for StackDecipher { + type Ok<T> + = Result<T, Unspecified> + where + T: Send + 'c; + + type Passthrough = BoxedPassthrough; + + fn map_ok<T, U, F>(ok: Self::Ok<T>, f: F) -> Self::Ok<U> + where + T: Send + 'c, + U: Send + 'c, + F: FnOnce(T) -> U, + { + ok.map(f) + } + + fn decrypt_bytes<'a, V, A>(self, visitor: V, aad: A) -> Self::Ok<V::Value> + where + V: DecipherVisitor<'c> + Send + 'c, + A: IntoAad<'a>, + { + match self.ciphertext { + CipherText::Single(keyed) => { + let bytes = open_leaf(keyed, &aad.into_aad())?; + visitor.visit_bytes_vec(bytes) + } + _ => Err(Unspecified), + } + } + + fn decrypt_seq<'a, V, A>(self, visitor: V, aad: A) -> Self::Ok<V::Value> + where + V: DecipherVisitor<'c> + Send + 'c, + A: IntoAad<'a>, + { + match self.ciphertext { + // At least one non-passthrough item required: passthrough items + // authenticate nothing, so an all-passthrough (or entry-less) + // sequence would verify under any AAD. The encrypt side refuses to + // produce one; refuse to open one. Emptiness is only provable by + // the authenticated `EmptySequence` marker. + CipherText::Sequence(items) + if items + .iter() + .any(|i| !matches!(i, CipherText::Passthrough(_))) => + { + visitor.visit_seq(StackSeqAccess { + items: items.into_iter(), + element_aad: aad.into_aad().for_sequence_element(), + }) + } + CipherText::EmptySequence(keyed) => { + let aad = aad.into_aad(); + verify_empty_marker(keyed, &aad.for_empty_sequence())?; + // Store the element derivation exactly as the non-empty arm + // does: never read (the iterator is empty), but a divergent + // value here would silently break a visitor that consulted it. + visitor.visit_seq(StackSeqAccess { + items: Vec::new().into_iter(), + element_aad: aad.for_sequence_element(), + }) + } + _ => Err(Unspecified), + } + } + + fn decrypt_map<'a, V, A>(self, visitor: V, aad: A) -> Self::Ok<V::Value> + where + V: DecipherVisitor<'c> + Send + 'c, + A: IntoAad<'a>, + { + match self.ciphertext { + // At least one non-passthrough entry required — see `decrypt_seq`. + CipherText::Map(entries) + if entries + .iter() + .any(|(_, v)| !matches!(v, CipherText::Passthrough(_))) => + { + // Reject duplicate keys before the visitor sees any entry: two + // ciphertexts of the same logical record seal a given key's + // value against the identical `for_map_entry` AAD, so a stale + // entry appended to a current ciphertext *verifies* — with a + // last-wins visitor that is a single-field rollback. + let mut seen = HashSet::with_capacity(entries.len()); + if !entries.iter().all(|(key, _)| seen.insert(key.as_str())) { + return Err(Unspecified); + } + visitor.visit_map(StackMapAccess { + entries: entries.into_iter(), + aad: aad.into_aad(), + pending: None, + }) + } + CipherText::EmptyMap(keyed) => { + let aad = aad.into_aad(); + verify_empty_marker(keyed, &aad.for_empty_map())?; + // Raw caller AAD, not the marker derivation — see `decrypt_seq`. + visitor.visit_map(StackMapAccess { + entries: Vec::new().into_iter(), + aad, + pending: None, + }) + } + _ => Err(Unspecified), + } + } + + fn decrypt_any<'a, V, A>(self, visitor: V, aad: A) -> Self::Ok<V::Value> + where + V: DecipherVisitor<'c> + Send + 'c, + A: IntoAad<'a>, + { + match self.ciphertext { + ct @ CipherText::Single(_) => Self::over(ct).decrypt_bytes(visitor, aad), + ct @ (CipherText::Sequence(_) | CipherText::EmptySequence(_)) => { + Self::over(ct).decrypt_seq(visitor, aad) + } + ct @ (CipherText::Map(_) | CipherText::EmptyMap(_)) => { + Self::over(ct).decrypt_map(visitor, aad) + } + CipherText::None(keyed) => { + // Verify the domain-separated marker (tag AND empty plaintext) + // before reporting absence — an unauthenticated `visit_none` + // would let an attacker forge "absent" values, and a bare-AAD + // check would let a `Single` leaf be re-tagged as one. Mirrors + // `decrypt_option`. + verify_empty_marker(keyed, &aad.into_aad().for_none())?; + visitor.visit_none() + } + // A self-describing visitor recovers a passthrough via + // `visit_passthrough` (type-erased); visitors that do not override + // it inherit the default rejection. + CipherText::Passthrough(boxed) => visitor.visit_passthrough(boxed), + } + } + + fn decrypt_passthrough(self) -> Self::Ok<Self::Passthrough> { + match self.ciphertext { + CipherText::Passthrough(boxed) => Ok(boxed), + _ => Err(Unspecified), + } + } + + fn decrypt_option<'a, T, A>(self, aad: A) -> Self::Ok<Option<T>> + where + T: Decrypt<'c> + 'c, + A: IntoAad<'a>, + { + match self.ciphertext { + CipherText::None(keyed) => { + // Verify the tag over the domain-separated marker AAD AND that + // the sealed plaintext is actually empty. Without both, a + // `Single(leaf)` sealed under the same caller AAD could be + // re-tagged as `None(leaf)` and decrypt as Ok(None) — silent + // authenticated data deletion. + verify_empty_marker(keyed, &aad.into_aad().for_none())?; + Ok(None) + } + // Passthrough must never be decoded as an Option payload. + CipherText::Passthrough(_) => Err(Unspecified), + // Any other variant is the `Some` payload: recurse into `T` with + // the caller's AAD unchanged (there is no depth tag; the shape of + // nested options is decided by `T` at the call site, as in + // `AesDecipher`). + other => T::decrypt_with_aad(Self::over(other), aad).map(Some), + } + } +} + +struct StackSeqAccess { + items: std::vec::IntoIter<KeyedCipherText>, + /// [`Context::for_sequence_element`] of the caller's AAD, derived once at + /// construction and re-supplied per element by borrowing. Mirrors + /// `PendingSeqCipher::element_aad` on the encrypt side. + element_aad: Context<'static>, +} + +impl<'c> SeqAccess<'c> for StackSeqAccess { + type Error = Unspecified; + + fn next_element<T: Decrypt<'c> + 'c>(&mut self) -> Result<Option<T>, Self::Error> { + match self.items.next() { + Some(ct) => { + T::decrypt_with_aad(StackDecipher::over(ct), self.element_aad.clone()).map(Some) + } + None => Ok(None), + } + } +} + +struct StackMapAccess<'a> { + entries: std::vec::IntoIter<(String, KeyedCipherText)>, + aad: Context<'a>, + /// The entry handed out by `next_key` and not yet consumed by + /// `next_value` / `next_passthrough`. Held as ciphertext rather than + /// decrypted up front so the caller can choose the plaintext type after + /// seeing the key — see [`MapAccess::next_key`] — alongside the entry + /// AAD it was sealed under, derived once here so the key itself moves + /// out to the caller. + pending: Option<(Context<'static>, KeyedCipherText)>, +} + +impl<'c, 'a> MapAccess<'c> for StackMapAccess<'a> { + type Error = Unspecified; + + fn next_key(&mut self) -> Result<Option<String>, Self::Error> { + // A still-pending entry means the caller skipped a value. Refused + // rather than tolerated: an entry whose value is never opened is an + // entry whose AAD binding is never verified. + if self.pending.is_some() { + return Err(Unspecified); + } + match self.entries.next() { + Some((key, ct)) => { + // Mirror `PendingMapCipher::encrypt_value`: the value was + // sealed against `for_map_entry(key)`, so a swapped or + // renamed key fails when the entry is opened. + self.pending = Some((self.aad.for_map_entry(&key), ct)); + Ok(Some(key)) + } + None => Ok(None), + } + } + + fn next_value<T: Decrypt<'c> + 'c>(&mut self) -> Result<T, Self::Error> { + let (entry_aad, ct) = self.pending.take().ok_or(Unspecified)?; + T::decrypt_with_aad(StackDecipher::over(ct), entry_aad) + } + + fn next_passthrough(&mut self) -> Result<Box<dyn Any + Send + 'static>, Self::Error> { + match self.pending.take() { + Some((_key, CipherText::Passthrough(value))) => Ok(value), + // A sealed value under a key the caller asked to read as a + // passthrough: refuse rather than hand it back with its tag + // unchecked — but keep the entry pending. The refusal is the + // caller's answer, not a reason to lose the entry: it can still + // open it with `next_value`, and until it does `next_key` keeps + // refusing to move past it. + Some(entry) => { + self.pending = Some(entry); + Err(Unspecified) + } + None => Err(Unspecified), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn map_access(entries: Vec<(&str, KeyedCipherText)>) -> StackMapAccess<'static> { + StackMapAccess { + entries: entries + .into_iter() + .map(|(key, ct)| (key.to_string(), ct)) + .collect::<Vec<_>>() + .into_iter(), + aad: Context::from_encoded(b"map"), + pending: None, + } + } + + #[test] + fn a_passthrough_entry_is_read_back_as_the_boxed_value() { + let mut map = map_access(vec![("plain", CipherText::Passthrough(Box::new(7u32)))]); + + assert_eq!(map.next_key(), Ok(Some("plain".to_string()))); + let value = map.next_passthrough().expect("passthrough entry"); + assert_eq!(value.downcast_ref::<u32>(), Some(&7)); + assert_eq!(map.next_key(), Ok(None)); + } + + /// Asking for a sealed entry as a passthrough is refused, and the entry + /// stays pending: it is neither handed back unverified nor lost, so the + /// caller can still open it with `next_value` and cannot skip it. + #[test] + fn a_sealed_entry_survives_being_misread_as_a_passthrough() { + let mut map = map_access(vec![ + ("sealed", CipherText::Sequence(vec![])), + ("plain", CipherText::Passthrough(Box::new(7u32))), + ]); + + assert_eq!(map.next_key(), Ok(Some("sealed".to_string()))); + assert!(map.next_passthrough().is_err()); + // Still pending: the map refuses to advance past an unopened entry. + assert_eq!(map.next_key(), Err(Unspecified)); + // And the refusal is repeatable, not a one-shot that then drops it. + assert!(map.next_passthrough().is_err()); + assert_eq!(map.next_key(), Err(Unspecified)); + } + + /// A draw with nothing pending — before any `next_key`, or after the + /// entry has already been taken — is a clean refusal, never a value and + /// never a panic. This is the guard that keeps an absent or unverified + /// value from being handed back, so a refactor from `ok_or(..)?` to an + /// `unwrap` would regress silently without it. + #[test] + fn drawing_a_value_with_nothing_pending_is_refused() { + let mut map = map_access(vec![("plain", CipherText::Passthrough(Box::new(7u32)))]); + + // No `next_key` yet: nothing is pending. + assert!(map.next_value::<String>().is_err()); + assert!(map.next_passthrough().is_err()); + + // A legitimate draw consumes the entry, so a second draw of either + // kind is refused too. + assert_eq!(map.next_key(), Ok(Some("plain".to_string()))); + assert!(map.next_passthrough().is_ok()); + assert!(map.next_passthrough().is_err()); + assert!(map.next_value::<String>().is_err()); + assert_eq!(map.next_key(), Ok(None)); + } + + /// A cipher's `Debug` — and a keyset handle's — names the keyset and the + /// source's type and stops there: the cache behind a cipher holds every + /// loaded keyset's index key, a handle carries its keyset's, and the + /// backend holds the credentials. + #[tokio::test] + async fn debug_shows_the_keyset_and_the_source_type_only() { + let cipher = StackCipher::builder() + .kms(stack_kms::FakeDataKeySource::new()) + .init() + .await + .unwrap(); + let keyset = cipher.default_keyset(); + let keyset_id = keyset.keyset_id().to_string(); + + for (debug, type_name) in [ + (format!("{cipher:?}"), "StackCipher {"), + (format!("{keyset:?}"), "KeysetCipher {"), + ] { + assert!(debug.starts_with(type_name), "{debug}"); + assert!(debug.contains(&keyset_id), "{debug}"); + assert!(debug.contains("FakeDataKeySource"), "{debug}"); + assert!(debug.ends_with(", .. }"), "non-exhaustive: {debug}"); + } + } + + /// `decrypt_passthrough` hands back the payload of a passthrough node and + /// nothing else: any sealed shape is refused rather than surfaced + /// unopened, and a payload of another type is refused by the downcast. + #[test] + fn decrypt_passthrough_opens_only_a_passthrough() { + let passthrough = || StackDecipher::over(CipherText::Passthrough(Box::new(7u32))); + + assert_eq!( + passthrough().decrypt_passthrough_as::<u32>(), + Ok(7), + "a passthrough payload of the requested type should be handed back" + ); + assert_eq!( + passthrough().decrypt_passthrough_as::<String>(), + Err(Unspecified), + "a payload of another type should fail the downcast" + ); + assert_eq!( + StackDecipher::over(CipherText::Sequence(vec![])).decrypt_passthrough_as::<u32>(), + Err(Unspecified), + "a sealed shape should be refused rather than surfaced unopened" + ); + } + + /// Byte-level pin for the [`leaf_aad`] derivation. This is part of the + /// frozen leaf format: a change to the domain label, the version byte, + /// the keyset id's place, the piece order, or the PAE framing makes + /// every stored leaf fail authentication, so it must be deliberate — + /// and, once anything is stored, must come with a + /// [`SealedValue::FORMAT_VERSION`] bump, which this pin forces into view. + #[test] + fn leaf_aad_bytes_are_pinned() { + let keyset = Uuid::from_bytes(*b"keyset-fixture16"); + let aad = leaf_aad(&Context::from_encoded(b"caller-aad"), keyset, b"key-tag"); + let hex: String = aad.as_bytes().iter().map(|b| format!("{b:02x}")).collect(); + // PAE: LE64 count (5) ‖ per piece LE64 length ‖ piece, the pieces + // being "stack-encrypt/leaf", [FORMAT_VERSION], the keyset id's 16 + // bytes, the caller AAD, and the key tag. + assert_eq!( + hex, + "05000000000000001200000000000000737461636b2d656e63727970742f6c65616601000000000000000110000000000000006b65797365742d6669787475726531360a0000000000000063616c6c65722d61616407000000000000006b65792d746167" + ); + } +} diff --git a/packages/stack-encrypt/src/descriptor.rs b/packages/stack-encrypt/src/descriptor.rs new file mode 100644 index 000000000..b245c9c15 --- /dev/null +++ b/packages/stack-encrypt/src/descriptor.rs @@ -0,0 +1,551 @@ +//! The ZeroKMS **descriptor**: the context a data key is requested under, +//! rendered as the string ZeroKMS binds and logs. +//! +//! Every data-key request stack-encrypt makes — generate on encrypt, +//! retrieve on decrypt — carries the requesting context as its descriptor. +//! ZeroKMS HMACs the descriptor into the key `tag` it returns and requires +//! the same descriptor to re-derive the key, so a leaf sealed under +//! `users/email` cannot have its key retrieved under `users/name`: the +//! request fails at ZeroKMS, before any key material moves. The descriptor +//! is also what a ZeroKMS retrieval log records per key, which is what +//! makes the field readable in an audit trail. That is the legacy +//! `cipherstash-client` arrangement, on the stable descriptor channel. +//! Lock-context tags and decryption policies are a separate, newer channel +//! that stack-encrypt does not yet use. +//! +//! The descriptor is a string on the wire; a context is a value with parts +//! (its [`ContextPiece`] tree — text, bytes, integers, lists of those). +//! [`Descriptor::from_piece`] is the one rendering of those parts as a +//! string, and it is **frozen**: ZeroKMS binds the rendered string into the +//! tag, so changing the rendering strands every key issued under the old +//! one. +//! +//! The descriptor follows the context's **parts**, not its encoded bytes, +//! so it and the AEAD encoding can disagree about whether two contexts are +//! one. They disagree in both directions, each in named cases: +//! +//! * The descriptor is *finer* for a pre-encoded +//! [`Context`](vitaminc_aead::Context), which is one opaque bytes part. +//! `("tenant", 7u64)` renders `tenant|7u64`; the same tuple passed +//! through `into_aad()` first encodes to the same AAD bytes but renders +//! `b64:` + those bytes. ZeroKMS refuses what the AEAD would open. +//! * The descriptor is *coarser* for shapes that render alike but encode +//! apart: text and bytes with the same content render the same, and +//! `7i64` renders as `7u64`, but since vitaminc 0.5 every leaf carries +//! its type tag, so each pair is two contexts to the AEAD. ZeroKMS +//! issues one key for both and logs one descriptor; the AEAD still +//! refuses to open one under the other, so nothing opens that should +//! not, but the ZeroKMS binding alone does not separate them. +//! +//! So a value must be opened under the context in the same **shape** it was +//! sealed under — the structured value both times, or the encoded `Context` +//! both times, text or bytes as it was sealed — not merely one with the +//! same bytes, and not merely one with the same descriptor. + +use std::sync::Arc; + +use base64ct::{Base64, Encoding}; +use vitaminc_aead::{ContextPiece, IntoAad, IntoContext}; + +/// A context rendered as the string sent to ZeroKMS with every data-key +/// request. See the [module docs](self). +/// +/// Built from the same value a leaf is sealed under — a +/// [`NonEmpty<T>`](crate::NonEmpty) context on the target-directed path, the +/// caller's AAD on the cipher-directed one — so the descriptor and the leaf +/// AAD always agree. +/// +/// One rendering serves every keyed leaf of a tree: the string is shared, +/// so cloning a `Descriptor` into each leaf's request costs a pointer, not +/// a copy, however long the context or large the tree. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Descriptor(Arc<str>); + +impl Descriptor { + /// The prefix that marks a base64-rendered text or byte part. A textual + /// part that happens to begin with it is base64-rendered too, so the + /// prefix is unambiguous. + pub const BASE64_PREFIX: &'static str = "b64:"; + + /// The separator between the parts of a list. + pub const SEPARATOR: char = '|'; + + /// The longest descriptor ZeroKMS accepts, in bytes of the rendered + /// string: the protocol's [`MAX_DESCRIPTOR_LEN`](stack_kms::MAX_DESCRIPTOR_LEN). + /// ZeroKMS derives key material over a fixed block of that size holding + /// the descriptor, so a longer one cannot be bound. Every data-key + /// request checks its descriptors against this before anything is sent + /// ([`Error::DescriptorTooLong`](crate::Error::DescriptorTooLong)); a + /// context is free to be long, but what it renders to must fit — and + /// the base64 escape grows a part by a third, so an escaped part fits + /// less than a plain one. + pub const MAX_LEN: usize = stack_kms::MAX_DESCRIPTOR_LEN; + + /// Render `context` — any [`IntoContext`] type — from its parts. + /// + /// [`KeysetCipher::encrypt`](crate::KeysetCipher::encrypt) / + /// [`StackCipher::decrypt`](crate::StackCipher::decrypt) and the target-directed + /// leaves render the descriptor themselves; call this to see what a + /// context will look like in the ZeroKMS log, or to check that it + /// [`fits`](Self::fits) before sealing a large batch under it. + /// + /// ``` + /// use stack_encrypt::{nonempty, Descriptor}; + /// + /// // A textual context is its own descriptor. + /// assert_eq!(Descriptor::of("users/email").as_str(), "users/email"); + /// + /// // A composite renders its parts in order: a field bound to a row id. + /// let row = nonempty!("users/email").with(7u64); + /// assert_eq!(Descriptor::of(row).as_str(), "users/email|7u64"); + /// assert!(Descriptor::of(row).fits()); + /// + /// // Rendered from the parts, so it follows the encoding: integers are + /// // sign-blind, an empty part inside a list leaves a mark, and text that + /// // could read as another form is escaped. + /// assert_eq!(Descriptor::of(7i64), Descriptor::of(7u64)); + /// assert_eq!(Descriptor::of(Some("")).as_str(), "(b64:)"); + /// assert_eq!(Descriptor::of("a|b").as_str(), "b64:YXxi"); + /// ``` + pub fn of<'a>(context: impl IntoContext<'a>) -> Self { + Self::from_piece(&context.into_context()) + } + + /// Render a context's parts. + /// + /// # Frozen rendering + /// + /// * A **text** part, or a **bytes** part that is UTF-8, renders + /// **verbatim** when it is *plain*: non-empty, no control characters, + /// none of `|`, `(`, `)`, not beginning with + /// [`b64:`](Self::BASE64_PREFIX), and not beginning with an ASCII digit + /// or `-`. So a `&str` context — `users/email` — is its own + /// descriptor, readable in the ZeroKMS log. Any other text or bytes + /// part renders as `b64:` followed by the standard (padded) base64 of + /// its bytes; an **empty** part is therefore the bare prefix, `b64:`, + /// so `Some("")` is `(b64:)` and `None` is `()`. Text and bytes with + /// the same bytes render the same, though since vitaminc 0.5 they + /// encode differently: the rendering is of the parts, not the bytes. + /// * An **integer** part renders as its little-endian value bytes read + /// as an unsigned number, with the width as a suffix: `7u64`. The + /// width is part of the rendering and the signedness is not: `7i64` + /// is `7u64`, and `-3i32` is `4294967293u32`. Since vitaminc 0.5 the + /// leaf's type tag carries the signedness, so `7i64` and `7u64` are + /// two contexts to the AEAD; the rendering, frozen before that, does + /// not follow. + /// * A **list** renders its parts joined by [`|`](Self::SEPARATOR). At + /// the root, a list of two or more parts has no delimiters — + /// `nonempty!("users/email").with(7u64)` is `users/email|7u64` — and + /// any other list, nested or of fewer than two parts, is parenthesised: + /// `(users/email)`, `()`, `a|(b|c)`. + /// * At the root, the empty text or bytes part — the `()` AAD, or `""` — + /// renders as the empty string, which is what ZeroKMS receives when a + /// caller opts out of descriptors. + /// + /// The forms cannot be mistaken for one another (the plain-text rule + /// reserves exactly the characters the other forms begin with or + /// contain), so distinct part trees render apart except where the + /// rendering is deliberately blind: text against bytes, and signed + /// against unsigned of one width. A pre-encoded `Context` renders + /// apart from the parts it was built from. See the [module docs](self). + pub fn from_piece(piece: &ContextPiece<'_>) -> Self { + let mut out = String::new(); + Self::render(piece, true, &mut out); + Self(Arc::from(out)) + } + + fn render(piece: &ContextPiece<'_>, root: bool, out: &mut String) { + match piece { + ContextPiece::Text(text) => Self::render_bytes(text.as_bytes(), root, out), + ContextPiece::Bytes(bytes) => Self::render_bytes(bytes, root, out), + // Signed and unsigned of one width share their little-endian + // value bytes; `as` reinterprets, so they render the same. Their + // type tags differ on the AEAD side; the rendering is frozen + // and does not follow. + ContextPiece::U8(v) => Self::render_int(v, "u8", out), + ContextPiece::U16(v) => Self::render_int(v, "u16", out), + ContextPiece::U32(v) => Self::render_int(v, "u32", out), + ContextPiece::U64(v) => Self::render_int(v, "u64", out), + ContextPiece::U128(v) => Self::render_int(v, "u128", out), + ContextPiece::I8(v) => Self::render_int(&(*v as u8), "u8", out), + ContextPiece::I16(v) => Self::render_int(&(*v as u16), "u16", out), + ContextPiece::I32(v) => Self::render_int(&(*v as u32), "u32", out), + ContextPiece::I64(v) => Self::render_int(&(*v as u64), "u64", out), + ContextPiece::I128(v) => Self::render_int(&(*v as u128), "u128", out), + // A pre-encoded context is one opaque part: its bytes, escaped. + ContextPiece::Encoded(bytes) => Self::render_bytes(bytes, root, out), + ContextPiece::List(parts) => { + let bare = root && parts.len() >= 2; + if !bare { + out.push('('); + } + for (i, part) in parts.iter().enumerate() { + if i > 0 { + out.push(Self::SEPARATOR); + } + Self::render(part, false, out); + } + if !bare { + out.push(')'); + } + } + // `ContextPiece` is `#[non_exhaustive]`: a part this crate does not + // know renders by its bytes, which cannot collide with a plain + // rendering (the base64 form is reserved) and still binds. + other => Self::render_bytes(other.clone().into_aad().as_bytes(), root, out), + } + } + + fn render_bytes(bytes: &[u8], root: bool, out: &mut String) { + // The empty root is the empty descriptor; an empty part anywhere + // else must leave a mark, or `Some("")` and `None` would both read + // `()`. The base64 of nothing is nothing, so the mark is the bare + // prefix — which no plain text can begin with. + if bytes.is_empty() && root { + return; + } + match std::str::from_utf8(bytes) { + Ok(text) if Self::is_plain(text) => out.push_str(text), + _ => { + out.push_str(Self::BASE64_PREFIX); + out.push_str(&Base64::encode_string(bytes)); + } + } + } + + fn render_int(value: &impl std::fmt::Display, suffix: &str, out: &mut String) { + use std::fmt::Write as _; + // Writing to a `String` cannot fail. + let _ = write!(out, "{value}{suffix}"); + } + + /// Text that renders verbatim: non-empty, and nothing another form + /// begins with or contains. + fn is_plain(text: &str) -> bool { + !text.is_empty() + && !text.starts_with(Self::BASE64_PREFIX) + && !text.starts_with(|c: char| c.is_ascii_digit() || c == '-') + && !text + .chars() + .any(|c| c.is_control() || matches!(c, '|' | '(' | ')')) + } + + /// The rendered string, as sent to ZeroKMS. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The rendered length in bytes — what [`MAX_LEN`](Self::MAX_LEN) bounds. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Whether the rendering is the empty string (the `()` context). + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Whether ZeroKMS can bind this descriptor: its rendered length is at + /// most [`MAX_LEN`](Self::MAX_LEN). + pub fn fits(&self) -> bool { + self.len() <= Self::MAX_LEN + } + + /// [`fits`](Self::fits) as the error a request path reports: `Ok` to go + /// on, or the [`DescriptorTooLong`](crate::Error::DescriptorTooLong) that + /// refuses the whole batch before a single request is built. + pub(crate) fn check(&self) -> Result<(), crate::Error> { + if self.fits() { + Ok(()) + } else { + Err(crate::Error::DescriptorTooLong { len: self.len() }) + } + } +} + +impl std::fmt::Display for Descriptor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef<str> for Descriptor { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use vitaminc_aead::Context; + use vitaminc_protected::{nonempty, NonEmpty}; + + use super::*; + + #[test] + fn a_textual_context_is_its_own_descriptor() { + assert_eq!(Descriptor::of("users/email").as_str(), "users/email"); + assert_eq!( + Descriptor::of(nonempty!("users/email")).as_str(), + "users/email" + ); + assert_eq!( + Descriptor::of(String::from("naïve/ünïcode")).as_str(), + "naïve/ünïcode" + ); + assert_eq!( + Descriptor::of(b"users/email".as_slice()).as_str(), + "users/email", + "bytes that are text render as the text they encode to" + ); + assert_eq!( + Descriptor::of(Context::from_encoded(b"users/email")).as_str(), + "users/email", + "already-encoded AAD renders by its bytes" + ); + } + + #[test] + fn the_empty_context_renders_empty() { + // `()` and `""` encode to the same (empty) bytes: one descriptor. + assert_eq!( + Descriptor::of(()).as_str(), + "", + "unit context should render empty" + ); + assert_eq!( + Descriptor::of("").as_str(), + "", + "empty text should render empty" + ); + assert_eq!( + Descriptor::of(b"".as_slice()).as_str(), + "", + "empty bytes should render empty" + ); + assert!( + Descriptor::of(()).is_empty(), + "unit context should be empty" + ); + assert!( + !Descriptor::of("users/email").is_empty(), + "textual context should not be empty" + ); + } + + /// Every view of a descriptor is the one rendering ZeroKMS is sent. + #[test] + fn display_and_as_ref_are_the_rendering() { + let descriptor = Descriptor::of(nonempty!("users/email")); + assert_eq!(descriptor.to_string(), "users/email"); + assert_eq!(AsRef::<str>::as_ref(&descriptor), "users/email"); + } + + #[test] + fn integers_render_with_their_width_not_their_sign() { + // Pinned: the rendering is bound into the ZeroKMS key tag, so a change + // here strands every key generated under the old rendering. + assert_eq!(Descriptor::of(7u64).as_str(), "7u64"); + assert_eq!(Descriptor::of(7u32).as_str(), "7u32"); + assert_eq!( + Descriptor::of(u128::MAX).as_str(), + format!("{}u128", u128::MAX) + ); + // Signed integers encode to the same bytes as the unsigned of their + // width, so they render as it: a `7i64` column and a `7u64` column + // are one context. + assert_eq!(Descriptor::of(7i64).as_str(), "7u64"); + assert_eq!(Descriptor::of(-3i32).as_str(), "4294967293u32"); + assert_eq!(Descriptor::of(-1i8).as_str(), "255u8"); + assert_eq!( + Descriptor::of(i128::MIN).as_str(), + format!("{}u128", i128::MIN as u128) + ); + } + + #[test] + fn an_empty_part_leaves_a_mark() { + // The root empty context is the empty descriptor, but an empty part + // inside a list must not vanish: `Some("")` and `None` encode + // differently (a one-element list and an empty one). + assert_eq!(Descriptor::of(Some("")).as_str(), "(b64:)"); + assert_eq!(Descriptor::of(None::<&str>).as_str(), "()"); + assert_eq!(Descriptor::of(("", "")).as_str(), "b64:|b64:"); + assert_eq!( + Descriptor::of(nonempty!("users/email").with(Some(""))).as_str(), + "users/email|(b64:)" + ); + assert_eq!( + Descriptor::of(nonempty!("users/email").with(None::<&str>)).as_str(), + "users/email|()" + ); + assert_eq!( + Descriptor::of(nonempty!("users/email").with("")).as_str(), + "users/email|b64:" + ); + } + + #[test] + fn shapes_that_render_alike_are_distinct_contexts() { + // Text against bytes of one content, and signed against unsigned of + // one width, render the same: the rendering was frozen before + // vitaminc 0.5 tagged every leaf with its type. To the AEAD each + // pair is two contexts, so the descriptor is coarser than the + // encoding here — see the module docs. + fn check<'a>(a: impl IntoAad<'a> + Clone, b: impl IntoAad<'a> + Clone) { + let (da, db) = (Descriptor::of(a.clone()), Descriptor::of(b.clone())); + assert_eq!(da, db, "expected one descriptor, got {da} vs {db}"); + assert_ne!( + a.into_aad().as_bytes(), + b.into_aad().as_bytes(), + "{da}: expected the AEAD to separate the two shapes" + ); + } + check(7u64, 7i64); + check(-3i32, 4_294_967_293u32); + check("users/email", b"users/email".as_slice()); + check(("a|b", 7u64), (b"a|b".as_slice(), 7i64)); + + // The empty root renders as the empty string whatever its shape. + assert_eq!( + Descriptor::of(()), + Descriptor::of(""), + "the empty context and the empty text both render empty" + ); + } + + #[test] + fn the_descriptor_is_finer_than_the_encoding_for_a_pre_encoded_context() { + // A pre-encoded `Context` is one opaque bytes part: the descriptor + // cannot recover the parts it was built from, so it renders the + // bytes. Seal and open must present the context in the same shape. + let structured = Descriptor::of(("tenant", 7u64)); + let encoded = Descriptor::of(("tenant", 7u64).into_aad()); + assert_eq!(structured.as_str(), "tenant|7u64"); + assert!(encoded.as_str().starts_with(Descriptor::BASE64_PREFIX)); + assert_ne!(structured, encoded); + + // Before vitaminc 0.5, different shapes could encode to the same + // bytes (an empty list was a zero count, eight zero bytes, `0u64`). + // Typed leaves closed that: the two now differ on the AEAD side as + // they always did on the descriptor. + assert_ne!( + None::<&str>.into_aad().as_bytes(), + 0u64.into_aad().as_bytes(), + "typed leaves separate the empty list from 0u64 on the AEAD side" + ); + assert_eq!(Descriptor::of(None::<&str>).as_str(), "()"); + assert_eq!(Descriptor::of(0u64).as_str(), "0u64"); + } + + #[test] + fn composites_render_their_parts_in_order() { + assert_eq!( + Descriptor::of(nonempty!("users/email").with(7u64)).as_str(), + "users/email|7u64" + ); + assert_eq!( + Descriptor::of(NonEmpty::new("users/email").unwrap().with(7u64)), + Descriptor::of(("users/email", 7u64)), + "NonEmpty is transparent to the rendering" + ); + assert_eq!( + Descriptor::of(("tenant", ("users/email", 7u64))).as_str(), + "tenant|(users/email|7u64)", + "a nested list is parenthesised" + ); + assert_eq!( + Descriptor::of(Some("users/email")).as_str(), + "(users/email)", + "a one-part list is parenthesised even at the root" + ); + assert_eq!(Descriptor::of(None::<&str>).as_str(), "()"); + } + + #[test] + fn text_that_could_read_as_another_form_is_escaped() { + // Control characters. + assert_eq!(Descriptor::of("a\0b").as_str(), "b64:YQBi"); + assert_eq!( + Descriptor::of("line\nbreak").as_str(), + "b64:bGluZQpicmVhaw==" + ); + // The base64 prefix itself: `b64:YQ==` as *text* must not collide + // with the rendering of the byte `a`. + let text = Descriptor::of("b64:YQ=="); + assert_eq!(text.as_str(), "b64:YjY0OllRPT0="); + assert_ne!(text, Descriptor::of("a")); + // The list separator and delimiters. + assert_eq!(Descriptor::of("a|b").as_str(), "b64:YXxi"); + assert_eq!(Descriptor::of("(a)").as_str(), "b64:KGEp"); + // A leading digit or sign, which is how an integer begins. + assert_eq!(Descriptor::of("7u64").as_str(), "b64:N3U2NA=="); + assert_eq!(Descriptor::of("-x").as_str(), "b64:LXg="); + } + + #[test] + fn the_limit_is_on_rendered_bytes() { + // 512 two-byte characters render to 1024 bytes: over, though the + // context is 512 characters "long". + assert!(Descriptor::of("a".repeat(512)).fits()); + assert!(!Descriptor::of("a".repeat(513)).fits()); + assert!(!Descriptor::of("ü".repeat(512)).fits()); + assert_eq!(Descriptor::of("ü".repeat(256)).len(), 512); + // The escape grows a part: 400 bytes of text with a `|` renders as + // `b64:` + 536 base64 characters. + let escaped = Descriptor::of(format!("|{}", "a".repeat(399))); + assert_eq!(escaped.len(), 4 + 536); + assert!(!escaped.fits()); + } + + #[test] + fn a_clone_shares_the_rendering() { + let descriptor = Descriptor::of("a".repeat(Descriptor::MAX_LEN)); + let clone = descriptor.clone(); + assert!( + std::ptr::eq(descriptor.as_str(), clone.as_str()), + "a clone must not copy the string: one rendering serves every leaf" + ); + } + + #[test] + fn invalid_utf8_renders_base64() { + assert_eq!(Descriptor::of(&[0xff, 0xfe][..]).as_str(), "b64://4="); + } + + #[test] + fn distinct_encodings_never_share_a_descriptor() { + let all = [ + Descriptor::of("users/email"), + Descriptor::of("b64:users/email"), + Descriptor::of(nonempty!("users/email").with(7u64)), + Descriptor::of(nonempty!("users/email").with(8u64)), + Descriptor::of(nonempty!("users/email").with(7u32)), + Descriptor::of("users/email|7u64"), + Descriptor::of(("users/email", ("7u64", ()))), + Descriptor::of(Some("users/email")), + Descriptor::of("(users/email)"), + Descriptor::of(None::<&str>), + Descriptor::of(Some("")), + Descriptor::of(("", "")), + Descriptor::of(nonempty!("users/email").with(None::<&str>)), + Descriptor::of(nonempty!("users/email").with(Some(""))), + Descriptor::of(nonempty!("users/email").with("")), + Descriptor::of("()"), + Descriptor::of("(b64:)"), + Descriptor::of("b64:"), + Descriptor::of(7u64), + Descriptor::of(7u32), + Descriptor::of(-7i64), + Descriptor::of(0u64), + Descriptor::of("7"), + Descriptor::of(&[0xff, 0xfe][..]), + Descriptor::of(()), + ]; + for (i, a) in all.iter().enumerate() { + for (j, b) in all.iter().enumerate() { + assert_eq!(i == j, a == b, "{a} vs {b}"); + } + } + } +} diff --git a/packages/stack-encrypt/src/dynamic/context.rs b/packages/stack-encrypt/src/dynamic/context.rs new file mode 100644 index 000000000..33d5ce61d --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/context.rs @@ -0,0 +1,460 @@ +//! An [`FfiValue`] read as an encryption context. See [`context`](context()). + +use std::borrow::Cow; + +use vitaminc_aead_value::FfiValue; +use vitaminc_protected::Controlled; + +use super::Error; +use crate::{ContextPiece, NonEmpty}; + +/// A context arrives from a binding as a value and becomes a [`ContextPiece`] +/// tree: vitaminc's runtime form of a context, and the *identity* of one. +/// vitaminc's law (pinned there by quickcheck over every built-in context +/// type) is that a context's two derivations each equal the same derivation +/// of its parts view: +/// +/// ```text +/// x.into_aad() == x.into_context().into_aad() +/// x.into_prf_context() == x.into_context().into_prf_context() +/// ``` +/// +/// So a `#[derive(EncryptFrom)]` row sealed with +/// `encrypt_into_with_context(row, 7u64)`, which binds each field under +/// `("users/age", 7u64)` — a `NonEmpty<(&str, u64)>` — and a binding that +/// spells the same context as `["users/age", 7u64]` agree byte for byte on +/// the AAD (the ciphertext binding and the ZeroKMS descriptor rendered from +/// its parts) *and* on the PRF context (the index terms' domain separation). +/// Nothing is re-derived here: the tree is handed to vitaminc's own impls. +/// +/// # Shape +/// +/// ```text +/// context := <string> | <bytes> | <i32> | <i64> | <u32> | <u64> | [ context, ... ] +/// ``` +/// +/// A bare string is one text part. An array is a list, and may nest as deep +/// as the transport codec allows +/// ([`MAX_DEPTH`](vitaminc_aead_value::transport::MAX_DEPTH) levels, counted +/// from the root of the encoded value); a deeper value is refused by the +/// codec before this module sees it. Text and bytes with the same content +/// are distinct contexts (UTF-8 versus bytes typed leaves) — the same +/// distinction the Rust types make. Booleans, floats, null, undefined, +/// objects and passthroughs are not contexts. +/// +/// # Which Rust contexts a list spells +/// +/// * `["users/age", 7u64]` is `nonempty!("users/age").with(7u64)`: a +/// two-element list is the pair. +/// * `NonEmpty::with` nests to the **left**: `nonempty!("a").with(7u64) +/// .with("eu")` is `(("a", 7u64), "eu")`, spelled `[["a", 7u64], "eu"]`. +/// A flat three-element list is a different context (a three-part PAE) +/// that no `.with()` chain produces. +/// * `[x]` is `Some(x)` and `[]` is `None`, on both derivations. A +/// one-element list is *not* the bare part: it is PAE-framed, the bare +/// part is not. +/// +/// # Emptiness +/// +/// [`context`](context()) returns a [`NonEmpty`], proven once here by +/// vitaminc's own rule for the tree: an empty string or byte string is +/// empty, an integer never is, and a list is empty when every part is (so +/// `[]` and `[""]` are, `["", 7]` is not) — the rule its `Option` and tuple +/// impls follow. +/// +/// # Examples +/// +/// The list a binding spells and the tuple a Rust caller writes are one +/// context: +/// +/// ``` +/// use stack_encrypt::dynamic::{context, FfiValue}; +/// use stack_encrypt::{nonempty, IntoAad}; +/// +/// let parsed = context(FfiValue::Array(vec![ +/// FfiValue::String("users/age".into()), +/// FfiValue::UInt64(7), +/// ]))?; +/// let typed = nonempty!("users/age").with(7u64); +/// assert_eq!( +/// parsed.into_inner().into_aad().as_bytes(), +/// typed.into_aad().as_bytes() +/// ); +/// # Ok::<(), stack_encrypt::dynamic::Error>(()) +/// ``` +/// +/// # Errors +/// +/// [`Error::Context`] for anything outside the shape above, and for a +/// context that renders empty. +pub fn context(value: FfiValue) -> Result<NonEmpty<ContextPiece<'static>>, Error> { + NonEmpty::new(piece_of(value)?).map_err(|_| Error::Context) +} + +fn piece_of(value: FfiValue) -> Result<ContextPiece<'static>, Error> { + Ok(match value { + // Valid UTF-8 by `Utf8String`'s construction invariant; checked + // rather than assumed because this is boundary code. The payload + // moves out of its `Protected` rather than being copied: a context + // is not secret, and the copy would only be wiped and freed. + FfiValue::String(s) => ContextPiece::Text(Cow::Owned( + String::from_utf8(s.into_inner().risky_unwrap()).map_err(|_| Error::Context)?, + )), + FfiValue::Bytes(bytes) => ContextPiece::Bytes(Cow::Owned(bytes.risky_unwrap())), + FfiValue::Int32(v) => ContextPiece::I32(v), + FfiValue::Int64(v) => ContextPiece::I64(v), + FfiValue::UInt32(v) => ContextPiece::U32(v), + FfiValue::UInt64(v) => ContextPiece::U64(v), + // Nesting depth is bounded by the codec's `MAX_DEPTH` before the + // value reaches here. + FfiValue::Array(items) => ContextPiece::List( + items + .into_iter() + .map(piece_of) + .collect::<Result<Vec<_>, Error>>()?, + ), + FfiValue::Null + | FfiValue::Undefined + | FfiValue::Bool(_) + | FfiValue::Float32(_) + | FfiValue::Float64(_) + | FfiValue::Object(_) + | FfiValue::Passthrough(_) => return Err(Error::Context), + }) +} + +/// A view of a context tree that borrows its text and bytes, so a context +/// parsed and proven once can be handed to every output of every row without +/// copying the payloads. Integers are copied (they are the payload); the +/// list spine is rebuilt, which is the cost of a tree of `Cow`s rather than +/// a tree of references. +/// +/// [`ContextPiece`] is `#[non_exhaustive]`, so a variant this crate does not +/// know is cloned whole rather than refused: the view must be the same +/// context, and a clone is. +pub fn borrowed<'b>(piece: &'b ContextPiece<'_>) -> ContextPiece<'b> { + match piece { + ContextPiece::Text(text) => ContextPiece::Text(Cow::Borrowed(text.as_ref())), + ContextPiece::Bytes(bytes) => ContextPiece::Bytes(Cow::Borrowed(bytes.as_ref())), + ContextPiece::U8(v) => ContextPiece::U8(*v), + ContextPiece::U16(v) => ContextPiece::U16(*v), + ContextPiece::U32(v) => ContextPiece::U32(*v), + ContextPiece::U64(v) => ContextPiece::U64(*v), + ContextPiece::U128(v) => ContextPiece::U128(*v), + ContextPiece::I8(v) => ContextPiece::I8(*v), + ContextPiece::I16(v) => ContextPiece::I16(*v), + ContextPiece::I32(v) => ContextPiece::I32(*v), + ContextPiece::I64(v) => ContextPiece::I64(*v), + ContextPiece::I128(v) => ContextPiece::I128(*v), + ContextPiece::Encoded(bytes) => ContextPiece::Encoded(Cow::Borrowed(bytes.as_ref())), + ContextPiece::List(parts) => ContextPiece::List(parts.iter().map(borrowed).collect()), + other => other.clone().into_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{nonempty, Descriptor, IntoAad, IntoPrfContext}; + use vitaminc_protected::Protected; + + fn s(value: &str) -> FfiValue { + FfiValue::String(value.into()) + } + + /// `borrowed` is a view, so it must be the same context for every + /// variant: one list holding each integer width, each byte-bearing + /// piece, and a nested list is its own borrowed view. A variant this + /// match forgot would fall to the cloning arm and still compare equal, + /// so the test also pins that no payload was copied where a borrow was + /// due — the text and bytes come back as `Cow::Borrowed`. + #[test] + fn a_borrowed_view_is_the_same_context_for_every_piece() { + let owned = ContextPiece::List(vec![ + ContextPiece::Text(Cow::Owned("users/age".to_string())), + ContextPiece::Bytes(Cow::Owned(vec![1, 2, 3])), + ContextPiece::Encoded(Cow::Owned(vec![4, 5, 6])), + ContextPiece::U8(8), + ContextPiece::U16(16), + ContextPiece::U32(32), + ContextPiece::U64(64), + ContextPiece::U128(128), + ContextPiece::I8(-8), + ContextPiece::I16(-16), + ContextPiece::I32(-32), + ContextPiece::I64(-64), + ContextPiece::I128(-128), + ContextPiece::List(vec![ContextPiece::Text(Cow::Owned("t".to_string()))]), + ]); + + let view = borrowed(&owned); + assert_eq!(view, owned, "the view is the same context"); + + let ContextPiece::List(parts) = &view else { + panic!("the view of a list is a list"); + }; + assert!( + matches!(&parts[0], ContextPiece::Text(Cow::Borrowed(_))), + "text is borrowed, not copied" + ); + assert!( + matches!(&parts[1], ContextPiece::Bytes(Cow::Borrowed(_))), + "bytes are borrowed, not copied" + ); + assert!( + matches!(&parts[2], ContextPiece::Encoded(Cow::Borrowed(_))), + "encoded bytes are borrowed, not copied" + ); + } + + #[test] + fn a_bare_string_is_the_flat_context() { + let parsed = context(s("users/age")).expect("flat context"); + assert_eq!( + parsed.get(), + &ContextPiece::Text(Cow::Borrowed("users/age")), + "a bare string is one text part, not a one-element list" + ); + assert_eq!( + parsed.into_inner().into_aad().as_bytes(), + "users/age".into_aad().as_bytes(), + "the AAD is the string's own, unframed" + ); + } + + #[test] + fn a_list_encodes_as_the_tuple_on_both_sides() { + let parsed = context(FfiValue::Array(vec![s("users/age"), FfiValue::UInt64(7)])) + .expect("extended context"); + let tuple = nonempty!("users/age").with(7u64); + assert_eq!( + parsed.clone().into_inner().into_aad().as_bytes(), + tuple.into_aad().as_bytes(), + "a two-element list is the pair on the AAD side" + ); + assert_eq!( + parsed.into_inner().into_prf_context().as_bytes(), + tuple.into_prf_context().as_bytes(), + "a two-element list is the pair on the PRF side" + ); + } + + #[test] + fn a_list_renders_the_descriptor_the_tuple_does() { + let parsed = context(FfiValue::Array(vec![s("users/age"), FfiValue::UInt64(7)])) + .expect("extended context"); + assert_eq!( + Descriptor::of(parsed.into_inner()).as_str(), + "users/age|7u64", + "the list renders its parts joined by `|`" + ); + assert_eq!( + Descriptor::of(nonempty!("users/age").with(7u64)).as_str(), + "users/age|7u64", + "the tuple renders the same descriptor" + ); + } + + #[test] + fn a_nested_list_encodes_as_the_nested_tuple() { + let parsed = context(FfiValue::Array(vec![ + s("users/age"), + FfiValue::Array(vec![s("t"), FfiValue::Int32(-3)]), + ])) + .expect("nested context"); + let tuple = ("users/age", ("t", -3i32)); + assert_eq!( + parsed.clone().into_inner().into_aad().as_bytes(), + tuple.into_aad().as_bytes(), + "a nested list is the nested tuple on the AAD side" + ); + assert_eq!( + parsed.into_inner().into_prf_context().as_bytes(), + tuple.into_prf_context().as_bytes(), + "a nested list is the nested tuple on the PRF side" + ); + } + + /// The borrowed view is the same context as the owned tree. + #[test] + fn the_borrowed_view_encodes_as_the_owned_tree() { + let parsed = context(FfiValue::Array(vec![ + s("users/age"), + FfiValue::Array(vec![ + FfiValue::Bytes(Protected::new(b"k".to_vec())), + FfiValue::Int64(-1), + ]), + ])) + .expect("context"); + let owned = parsed.clone().into_inner(); + let view = + NonEmpty::new(borrowed(parsed.get())).expect("a non-empty context borrows non-empty"); + assert_eq!(view.get(), &owned, "the view is a different tree"); + assert_eq!( + view.clone().into_inner().into_aad().as_bytes(), + owned.clone().into_aad().as_bytes(), + "AAD bytes differ between the borrowed view and the owned tree" + ); + assert_eq!( + view.into_inner().into_prf_context().as_bytes(), + owned.into_prf_context().as_bytes(), + "PRF bytes differ between the borrowed view and the owned tree" + ); + } + + /// `NonEmpty::with` nests to the left, so a `.with().with()` chain is + /// the left-nested list; a flat list of three is a different context. + #[test] + fn a_left_nested_list_is_the_with_chain() { + let chain = nonempty!("a").with(7u64).with("eu"); + let nested = context(FfiValue::Array(vec![ + FfiValue::Array(vec![s("a"), FfiValue::UInt64(7)]), + s("eu"), + ])) + .expect("nested") + .into_inner(); + let flat = context(FfiValue::Array(vec![s("a"), FfiValue::UInt64(7), s("eu")])) + .expect("flat") + .into_inner(); + assert_eq!( + nested.clone().into_aad().as_bytes(), + chain.into_aad().as_bytes(), + "the left-nested list is not the with-chain on the AAD side" + ); + assert_eq!( + nested.clone().into_prf_context().as_bytes(), + chain.into_prf_context().as_bytes(), + "the left-nested list is not the with-chain on the PRF side" + ); + assert_ne!( + flat.clone().into_aad().as_bytes(), + nested.clone().into_aad().as_bytes(), + "a flat three-part list must not collide with the nested pair" + ); + assert_ne!( + flat.into_prf_context().as_bytes(), + nested.into_prf_context().as_bytes(), + "a flat three-part list must not collide with the nested pair" + ); + } + + /// `[x]` is `Some(x)` on the AAD, the descriptor and the PRF side + /// (vitaminc 0.4.0 made the `Option` PRF context follow its parts view). + #[test] + fn a_one_element_list_is_some() { + let list = context(FfiValue::Array(vec![FfiValue::UInt64(7)])) + .expect("list") + .into_inner(); + let some = Some(7u64); + assert_eq!( + list.clone().into_aad().as_bytes(), + some.into_aad().as_bytes(), + "[x] and Some(x) share AAD bytes" + ); + assert_eq!( + Descriptor::of(list.clone()).as_str(), + Descriptor::of(some).as_str(), + "[x] and Some(x) render the same descriptor" + ); + assert_eq!( + list.into_prf_context().as_bytes(), + some.into_prf_context().as_bytes(), + "[x] and Some(x) share the PRF context" + ); + } + + #[test] + fn a_one_element_list_is_not_the_bare_part() { + let list = context(FfiValue::Array(vec![s("a")])).expect("list"); + let bare = context(s("a")).expect("bare"); + assert_ne!( + list.clone().into_inner().into_aad().as_bytes(), + bare.clone().into_inner().into_aad().as_bytes(), + "[x] is PAE-framed and x is not, so their AAD differs" + ); + assert_ne!( + list.into_inner().into_prf_context().as_bytes(), + bare.into_inner().into_prf_context().as_bytes(), + "[x] is PAE-framed and x is not, so their PRF context differs" + ); + } + + /// Text and bytes are typed leaves, so the same content is two contexts + /// on both sides (vitaminc 0.5; before it they shared AAD bytes). + #[test] + fn text_and_bytes_are_distinct_contexts_on_both_sides() { + let text = context(s("ab")).expect("text").into_inner(); + let bytes = context(FfiValue::Bytes(Protected::new(b"ab".to_vec()))) + .expect("bytes") + .into_inner(); + assert_ne!( + text.clone().into_aad().as_bytes(), + bytes.clone().into_aad().as_bytes(), + "text and bytes of the same content are distinct AAD" + ); + assert_ne!( + text.into_prf_context().as_bytes(), + bytes.into_prf_context().as_bytes(), + "text and bytes are distinct PRF encodings" + ); + } + + #[test] + fn emptiness_follows_the_tuple_rule() { + for (label, empty) in [ + ("an empty string", s("")), + ("empty bytes", FfiValue::Bytes(Protected::new(Vec::new()))), + ("an empty list", FfiValue::Array(vec![])), + ("a list of one empty string", FfiValue::Array(vec![s("")])), + ( + "a list of empties", + FfiValue::Array(vec![FfiValue::Array(vec![]), s("")]), + ), + ] { + assert!( + matches!(context(empty), Err(Error::Context)), + "{label} is empty by the tuple rule and must be refused" + ); + } + for (label, non_empty) in [ + ("a zero integer", FfiValue::UInt64(0)), + ( + "an empty string beside an integer", + FfiValue::Array(vec![s(""), FfiValue::Int32(0)]), + ), + ( + "a nested non-empty list", + FfiValue::Array(vec![FfiValue::Array(vec![s("x")])]), + ), + ] { + assert!( + context(non_empty).is_ok(), + "{label} carries bytes and must be accepted" + ); + } + } + + #[test] + fn non_context_values_are_encoding_errors() { + for (label, bad) in [ + ("null", FfiValue::Null), + ("undefined", FfiValue::Undefined), + ("a boolean", FfiValue::Bool(true)), + ("a float32", FfiValue::Float32(1.0)), + ("a float64", FfiValue::Float64(1.0)), + ( + "an object", + FfiValue::Object(vec![("k".to_string(), s("v"))]), + ), + ( + "a list with a boolean in it", + FfiValue::Array(vec![s("ok"), FfiValue::Bool(false)]), + ), + ] { + assert!( + matches!(context(bad), Err(Error::Context)), + "{label} is not a context and must be refused" + ); + } + } +} diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs new file mode 100644 index 000000000..5d7ccab83 --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -0,0 +1,172 @@ +//! Encrypting values whose type is known only at runtime. +//! +//! Everything else in this crate is typed: a target names its source type, +//! and the operations it composes are reached through bounds — `Encrypt` for +//! a ciphertext, [`PrfValue`](vitaminc_prf::PrfValue) for an equality term, +//! `AsRef<str>` for a match term. That is what makes a term a cross-language +//! contract: `equality_term(34u32)` derives the same bytes wherever it is +//! called from, because `34u32` is the same value everywhere. +//! +//! An FFI binding cannot reach those bounds. Its field types arrive as wire +//! data, so there is no Rust type to name — and no dynamic value can satisfy +//! `AsRef<str>`, which is total. Something has to look at the value and pick +//! the typed operation. This module is that something, written once here +//! rather than once per language binding. +//! +//! The runtime value is [`FfiValue`], vitaminc's language-neutral value tree +//! and the type every binding already funnels through. +//! +//! # What is here +//! +//! * [`context`](context()) — an [`FfiValue`] read as an encryption context. +//! * [`term`](term()) — one index term for a value, dispatched on its variant. +//! * [`record`] — the runtime form of `#[derive(EncryptFrom)]`: a *plan* +//! says per field what context to bind and what outputs to produce, and +//! the whole call seals from one batched key request. +//! * [`Scope`] — which cipher an opening operation decrypts through. +//! +//! # What is not here +//! +//! Encrypting a whole value is not: [`FfiValue`] implements `Encrypt` +//! already, so `keyset.encrypt(value, aad)` is the whole of it and needs +//! nothing from this module. +//! +//! # Stability +//! +//! The output keys this module spells (`"c"`, `"eq"`, `"match"`, `"ore"`, +//! `"ope"`) are **wire format**, not just API: they are map keys in stored +//! ciphertext, so a row written under one spelling is read under the same +//! spelling or not at all. They are fixed here so that bindings in different +//! languages agree on them by construction rather than by each re-deriving +//! them. Their long-term home is beside vitaminc's frozen tag table, which +//! already owns this class of constant. +//! +//! For the same reason the enums that spell them — [`Output`] and +//! [`TermKind`] — are *not* `#[non_exhaustive]`, against this workspace's +//! usual rule for public enums: a new output is a wire-format addition every +//! binding has to be taught, and an exhaustive match is how the compiler +//! tells a binding author that. [`Scope`] is exhaustive for a different +//! reason, given on the type. +mod context; +pub mod record; +mod term; + +use std::fmt; + +pub use context::{borrowed, context}; +pub use record::{FieldPlan, Output, Plan}; +pub use term::{term, Scalar, TermKind}; +/// vitaminc's language-neutral value tree — the runtime value every binding +/// funnels through. Its transport codec is `vitaminc_aead_value::transport`, +/// which stays the binding's: this crate takes and returns values, never +/// encoded bytes. +pub use vitaminc_aead_value::FfiValue; + +use crate::{KeysetCipher, StackCipher}; + +/// Which cipher an opening operation decrypts through: the client, or one +/// of its keysets. +/// +/// This is the runtime form of the crate's *scope* (what a `Pending` is +/// built through, and so what it may open — [`CipherScope`](crate::CipherScope) +/// is the trait both ciphers implement). [`StackCipher`] and +/// [`KeysetCipher`] both open, and neither is the other's supertype: the +/// client opens a leaf sealed under any of its keysets, while a keyset +/// cipher opens only its own and fails a foreign leaf with +/// [`Error::ForeignKeyset`](crate::Error::ForeignKeyset). That refusal is +/// the keyset cipher's, made when the pending is built and before any key +/// is retrieved; this enum only names which of the two a call goes through, +/// because a binding's caller makes that choice at runtime and a typed +/// caller makes it by naming the cipher. +/// +/// Not `#[non_exhaustive]`: the two variants are the two ciphers this crate +/// has, and a binding dispatches on them (the Go guest does, per selector). +/// A third would be a new cipher type, which is a larger change than adding +/// a variant here. +pub enum Scope<'c, K> { + /// Leaves from any keyset the client holds: one batched retrieval per + /// keyset the leaves were sealed under. + Client(&'c StackCipher<K>), + /// Leaves from this keyset only. + Keyset(KeysetCipher<'c, K>), +} + +// By hand rather than derived, so `K: Debug` is not demanded: neither cipher +// demands it of its own `Debug`, and a data-key source rarely offers one. +impl<K> fmt::Debug for Scope<'_, K> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Scope::Client(cipher) => f.debug_tuple("Client").field(cipher).finish(), + Scope::Keyset(keyset) => f.debug_tuple("Keyset").field(keyset).finish(), + } + } +} + +/// The UTF-8 inside a string leaf. Valid by `Utf8String`'s construction +/// invariant; checked rather than assumed because this is boundary code. +fn utf8(s: &vitaminc_aead_value::Utf8String) -> Option<&str> { + std::str::from_utf8(s.risky_ref()).ok() +} + +/// What went wrong in a dynamic operation. +/// +/// The split that matters to a caller is malformed input versus something +/// else: every variant but [`Cipher`](Error::Cipher) and +/// [`Internal`](Error::Internal) is a statement about the value or the +/// request, decided before any key is minted or retrieved. `Cipher` is the +/// operation failing; `Internal` is this module's own bug. A binding maps +/// them to its own status codes on those lines, and must not report +/// `Internal` as the caller's fault. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// A value used as an encryption context is not one — a boolean, float, + /// null, object or passthrough, or a string that is not UTF-8 — or it is + /// a context that renders empty. Leaves take a + /// [`NonEmpty`](vitaminc_protected::NonEmpty) and nothing else, so an + /// empty context is refused where it is read rather than sealed under. + #[error("value cannot be read as a non-empty encryption context")] + Context, + + /// A term was asked for a value the scheme defines no such term for: a + /// container, null or passthrough (which have no term semantics at all), + /// or a scalar outside the kind's domain — equality over a float or a + /// boolean, match over anything but text. See + /// [`TermKind::supports`]. + #[error("no {kind} term is defined for this value")] + Term { + /// The kind that was asked for. + kind: TermKind, + }, + + /// A record plan is malformed: not an object of field specs, empty, + /// missing or duplicating an output, or carrying a key that is not + /// `"context"` or `"outputs"`. + #[error("record plan is malformed")] + Plan, + + /// A record source does not fit its plan: not an object (or an array of + /// them), a field the plan does not name, a plan field the source does + /// not carry or carries twice, or a passthrough or a repeated map key + /// under a field the plan seals. + #[error("record source does not fit the plan")] + Source, + + /// A stored record does not fit its plan: not a map (or a sequence of + /// them), a ciphertext-bearing field that is absent or given twice, or + /// has no `"c"` node or two of them, a repeated map key under `"c"`, or + /// a passthrough under `"c"` — which would hand back unauthenticated + /// bytes as if they had been opened. + #[error("stored record does not fit the plan")] + Record, + + /// An invariant this module maintains did not hold — a slot count that + /// did not line up, a re-proof that should not have been able to fail. + /// Always a bug here, never a statement about the caller's data. + #[error("internal invariant violated")] + Internal, + + /// Sealing, opening or deriving failed. + #[error(transparent)] + Cipher(#[from] crate::Error), +} diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs new file mode 100644 index 000000000..bd2392122 --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -0,0 +1,2036 @@ +//! Records: the runtime form of `#[derive(EncryptFrom)]`. +//! +//! A *plan* says, per field, which encryption context to bind and which +//! outputs to produce; the source supplies the field values. That is the +//! same job the derive does from a struct definition, done from data — which +//! is all a binding has. +//! +//! However many rows and fields are in one call, all ciphertext leaves seal +//! from **one** batched `generate_keys`: the pendings are merged before +//! settling, exactly like the derive's `zip`/`all` composition. Index terms +//! are *not* in that batch — [`encrypt`] settles each term as it builds the +//! row, which under the local HMAC backend is no ZeroKMS traffic at all, and +//! under a backend that derives terms at ZeroKMS would be one round trip per +//! term until the term pendings are merged into the row's batch. That is a +//! change for this module when such a backend lands, not something the +//! record path promises today. +//! +//! # One context per field, both halves +//! +//! A field's context is proven [`NonEmpty`] once, when the plan is built, +//! and one borrowed view of it — a single local in the row builder — drives +//! the field's ciphertext and every one of its terms. That is ADR-0004's +//! property. The typed path holds it with a type parameter threaded through +//! the declaration tree; this path has no tree to thread, sealing through +//! the cipher-directed `encrypt_with_aad` instead, so it holds it by one +//! variable: [`encrypt`] never has two contexts for a field in hand, so it +//! cannot seal the value under one and index it under another. That is +//! enforcement by shape rather than by type, and the tests here pin it — a +//! record's `"c"` opens under its plan context and its terms equal the +//! standalone derivation under that same context. +//! +//! A plan context is the *whole* context of its field. There is no caller +//! context to extend it with, so the plan spells the extension itself: a +//! bare string matches a Rust record sealed with `encrypt_into` (no caller +//! context); a list matches one sealed with `encrypt_into_with_context` — +//! see [`super::context`](super::context()) for which list spells which Rust +//! context. Rows are readable across the two however they were sealed, +//! provided the plan names the context the row was sealed under. +//! +//! # Terms ride as passthrough +//! +//! A term is a comparand, not a ciphertext to open, and passthrough is its +//! honest encoding: the result tree carries each term as a +//! [`CipherText::Passthrough`] byte node beside the field's `"c"` subtree. +//! Under `"c"` itself a passthrough is refused in both directions, and that +//! is load-bearing: `decrypt_as` collects **zero** retrieve-requests for a +//! passthrough and returns its payload with no AEAD opened, so without the +//! decrypt-side refusal an attacker with write access to the stored tree +//! could replace a field's `"c"` subtree with a passthrough carrying forged +//! plaintext and have it reported as a successful decrypt. + +use stack_kms::DataKeySource; +use vitaminc_aead_value::FfiValue; +use vitaminc_protected::Protected; + +use super::{borrowed, term, utf8, Error, Scalar, Scope, TermKind}; +use crate::target::Pending; +use crate::{ + BoxedPassthrough, CipherText, ContextPiece, Encrypt, KeysetCipher, NonEmpty, StackCipherText, +}; + +/// What a plan field asks for. +/// +/// The strings are wire format twice over: they are how a binding spells an +/// output, *and* the keys of the per-field output map in the stored result. +/// That is why this enum is exhaustive — see the [module docs](super#stability). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum Output { + /// `"c"` — the field's [`StackCipherText`]. + Ciphertext, + /// An index term: `"eq"`, `"match"`, `"ore"` or `"ope"`. + Term(TermKind), +} + +impl Output { + /// The output a key names, or `None` for a key that is not one. + pub fn parse(s: &str) -> Option<Self> { + Some(match s { + "c" => Output::Ciphertext, + "eq" => Output::Term(TermKind::Equality), + "match" => Output::Term(TermKind::Match), + "ore" => Output::Term(TermKind::Ore), + "ope" => Output::Term(TermKind::Ope), + _ => return None, + }) + } + + /// The map key this output rides under. + pub fn key(self) -> &'static str { + match self { + Output::Ciphertext => "c", + Output::Term(kind) => kind.key(), + } + } +} + +/// One field of a record plan: what to call it, what context to bind it +/// under, and what to produce for it. +#[derive(Clone, Debug)] +pub struct FieldPlan { + name: String, + context: NonEmpty<ContextPiece<'static>>, + outputs: Vec<Output>, +} + +impl FieldPlan { + /// A field plan. + /// + /// The context is a proven [`NonEmpty`] because that proof has to happen + /// somewhere and here is the last place it can: the cipher-directed path + /// [`encrypt`] seals through accepts any AAD, so nothing downstream + /// would stop an empty context from being sealed under — and opening + /// goes through `decrypt_as`, which would then never open it. Build one + /// from a value with [`super::context`](super::context()). + /// + /// # Errors + /// + /// [`Error::Plan`] if `outputs` is empty or names an output twice. + pub fn new( + name: impl Into<String>, + context: NonEmpty<ContextPiece<'static>>, + outputs: Vec<Output>, + ) -> Result<Self, Error> { + if outputs.is_empty() { + return Err(Error::Plan); + } + for (at, output) in outputs.iter().enumerate() { + if outputs[..at].contains(output) { + return Err(Error::Plan); + } + } + Ok(Self { + name: name.into(), + context, + outputs, + }) + } + + /// The field's name — its key in the source and in the result. + pub fn name(&self) -> &str { + &self.name + } + + /// The context this field binds under, on both halves. + pub fn context(&self) -> &NonEmpty<ContextPiece<'static>> { + &self.context + } + + /// What the field produces. + pub fn outputs(&self) -> &[Output] { + &self.outputs + } + + /// Whether the field has a ciphertext to seal and open. + pub fn has_ciphertext(&self) -> bool { + self.outputs.contains(&Output::Ciphertext) + } + + /// A borrowed view of the context, so one proof serves every output of + /// every row without copying the payloads. + fn view(&self) -> Result<NonEmpty<ContextPiece<'_>>, Error> { + // The proof was made when the plan was built, so re-taking it over + // the same tree cannot fail. + NonEmpty::new(borrowed(self.context.get())).map_err(|_| Error::Internal) + } +} + +/// A record plan: the fields a record has, each with what to call it, what +/// context to bind it under, and what to produce for it. +/// +/// Opaque, because the operations over a plan rely on two properties of the +/// whole that no single [`FieldPlan`] can carry: there is at least one +/// field, and no two fields share a name. With a repeated name the source +/// check would accept a row that names the field once, and [`encrypt`] +/// would write a map with the same key twice — a stored record no reader +/// can take apart. Both the parser ([`plan`]) and the manual constructor +/// ([`Plan::new`]) go through the one check, so a plan in hand is a plan +/// that holds them, whichever way it was built. +#[derive(Clone, Debug)] +pub struct Plan { + fields: Vec<FieldPlan>, +} + +impl Plan { + /// A plan over `fields`, in the order given — which is the order of the + /// fields in every result. + /// + /// # Errors + /// + /// [`Error::Plan`] if `fields` is empty or names a field twice. + pub fn new(fields: Vec<FieldPlan>) -> Result<Self, Error> { + if fields.is_empty() { + return Err(Error::Plan); + } + for (at, field) in fields.iter().enumerate() { + if fields[..at].iter().any(|prior| prior.name == field.name) { + return Err(Error::Plan); + } + } + Ok(Self { fields }) + } + + /// The plan's fields, in result order. Never empty, and no two share a + /// name. + pub fn fields(&self) -> &[FieldPlan] { + &self.fields + } +} + +/// Read a record plan from a decoded value. +/// +/// The plan is an [`FfiValue::Object`]: +/// +/// ```text +/// { <field>: { "context": <context>, "outputs": [ "c" | "eq" | "match" | "ore" | "ope", ... ] }, ... } +/// ``` +/// +/// `<context>` is defined once, in [`super::context`](super::context()): a +/// string, bytes, an integer, or a list of those, with what each spells in +/// Rust and the emptiness rule. +/// +/// # Examples +/// +/// ``` +/// use stack_encrypt::dynamic::{record, FfiValue, Output, TermKind}; +/// +/// // As a binding would decode it from its caller: seal `age` under +/// // "users/age" and index it for equality. +/// let plan = record::plan(FfiValue::Object(vec![( +/// "age".to_string(), +/// FfiValue::Object(vec![ +/// ("context".to_string(), FfiValue::String("users/age".into())), +/// ( +/// "outputs".to_string(), +/// FfiValue::Array(vec![ +/// FfiValue::String("c".into()), +/// FfiValue::String("eq".into()), +/// ]), +/// ), +/// ]), +/// )]))?; +/// +/// assert_eq!(plan.fields().len(), 1); +/// assert_eq!(plan.fields()[0].name(), "age"); +/// assert_eq!( +/// plan.fields()[0].outputs(), +/// [Output::Ciphertext, Output::Term(TermKind::Equality)] +/// ); +/// # Ok::<(), stack_encrypt::dynamic::Error>(()) +/// ``` +/// +/// # Errors +/// +/// [`Error::Plan`] for a plan that is not an object of field specs, an +/// empty plan, a field named twice, a spec with a key other than +/// `"context"` and `"outputs"` or with either given twice or missing, or +/// an output list that is not a list of known output names, is empty, or +/// names an output twice. [`Error::Context`] for a `"context"` that is +/// present but is not a context, or renders empty. +/// +/// The transport codec refuses duplicate object keys before a binding's +/// value reaches here, but an [`FfiValue`] can be built with them directly +/// and this is a public parser, so it refuses them itself rather than +/// letting the last one win. +pub fn plan(value: FfiValue) -> Result<Plan, Error> { + let FfiValue::Object(entries) = value else { + return Err(Error::Plan); + }; + let mut fields: Vec<FieldPlan> = Vec::with_capacity(entries.len()); + for (name, spec) in entries { + let FfiValue::Object(spec) = spec else { + return Err(Error::Plan); + }; + let mut context: Option<NonEmpty<ContextPiece<'static>>> = None; + let mut outputs: Option<Vec<Output>> = None; + for (key, value) in spec { + match key.as_str() { + "context" if context.is_none() => context = Some(super::context(value)?), + "outputs" if outputs.is_none() => { + let FfiValue::Array(items) = value else { + return Err(Error::Plan); + }; + let mut parsed = Vec::with_capacity(items.len()); + for item in &items { + let FfiValue::String(s) = item else { + return Err(Error::Plan); + }; + let key = utf8(s).ok_or(Error::Plan)?; + parsed.push(Output::parse(key).ok_or(Error::Plan)?); + } + outputs = Some(parsed); + } + // An unknown key, or one of the two given twice. + _ => return Err(Error::Plan), + } + } + fields.push(FieldPlan::new( + name, + context.ok_or(Error::Plan)?, + outputs.ok_or(Error::Plan)?, + )?); + } + // The whole-plan rules — non-empty, no name twice — are `Plan::new`'s, + // so a parsed plan and a hand-built one are refused alike. + Plan::new(fields) +} + +/// Encrypt a record — or a batch of records — per a plan. +/// +/// `source` is an [`FfiValue::Object`] of `{ field: scalar }` (one record), +/// or an [`FfiValue::Array`] of such objects (a batch). Every plan field +/// must be present in each record, and every record field must be named by +/// the plan — silently dropping a field on either side would lose data or +/// index nothing. +/// +/// The result is per record a map of `field → { output-key → node }`, where +/// `"c"` is the field's sealed ciphertext subtree and each term rides as a +/// passthrough byte node. A batch is a sequence of such maps. +/// +/// # Examples +/// +/// ``` +/// use stack_encrypt::dynamic::{record, FfiValue, Scope}; +/// use stack_encrypt::StackCipher; +/// use stack_kms::FakeDataKeySource; +/// +/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +/// let cipher = StackCipher::builder() +/// .kms(FakeDataKeySource::new()) +/// .init() +/// .await?; +/// let keyset = cipher.default_keyset(); +/// +/// // Seal `age` under "users/age" with an equality term beside it. +/// let plan = record::plan(FfiValue::Object(vec![( +/// "age".to_string(), +/// FfiValue::Object(vec![ +/// ("context".to_string(), FfiValue::String("users/age".into())), +/// ( +/// "outputs".to_string(), +/// FfiValue::Array(vec![ +/// FfiValue::String("c".into()), +/// FfiValue::String("eq".into()), +/// ]), +/// ), +/// ]), +/// )]))?; +/// +/// let row = FfiValue::Object(vec![("age".to_string(), FfiValue::UInt32(34))]); +/// let sealed = record::encrypt(&keyset, row, &plan).await?; +/// +/// // Only the ciphertext comes back; the term is one-way. +/// let opened = record::decrypt(Scope::Client(&cipher), sealed, &plan).await?; +/// let FfiValue::Object(fields) = opened else { +/// unreachable!("one record opens to one object"); +/// }; +/// assert!(matches!(&fields[..], [(name, FfiValue::UInt32(34))] if name == "age")); +/// # Ok::<(), stack_encrypt::dynamic::Error>(()) +/// # }).unwrap(); +/// ``` +/// +/// # Cross-language note +/// +/// A `"c"` leaf seals the aead-value *tagged* plaintext encoding (`[type +/// tag] ++ payload`), because that tag table is the contract the bindings +/// share. A Rust `#[derive(EncryptFrom)]` over a plain primitive — a bare +/// `u32` — seals four untagged bytes instead, so a plain-primitive Rust +/// derive and a plan do **not** interchange ciphertexts for the same field +/// until the Rust side uses aead-value's tagged types too. This is by +/// design, not a defect in either side. +/// +/// # Errors +/// +/// [`Error::Source`] if the source does not fit the plan; [`Error::Term`] +/// if a value has no term the plan asks for; [`Error::Cipher`] if sealing +/// or deriving fails. +pub async fn encrypt<K>( + cipher: &KeysetCipher<'_, K>, + source: FfiValue, + plan: &Plan, +) -> Result<StackCipherText, Error> +where + K: DataKeySource + Sync, +{ + let Rows { rows, batched } = source_rows(source, plan)?; + + // Build every row: terms derive now (local), ciphertexts queue their + // data-key requests into one flat pending list. + let mut pendings: Vec<Pending<'_, StackCipherText, K>> = Vec::new(); + let mut skeletons: Vec<Vec<FieldSkeleton>> = Vec::with_capacity(rows.len()); + for row in rows { + skeletons.push(build_row(cipher, row, plan, &mut pendings).await?); + } + + // The one batched key request for the whole invocation. + let mut sealed = Settled::of(Pending::all(cipher, pendings).await?); + + // Fill the ciphertext slots back in, in build order. + let row_nodes = skeletons + .into_iter() + .map(|skeleton| { + let fields = skeleton + .into_iter() + .map(|field| { + let nodes = field + .outputs + .into_iter() + .map(|(key, slot)| { + let node = match slot { + Slot::Term(term) => CipherText::Passthrough(Box::new( + FfiValue::Bytes(Protected::new(term)), + ) + as BoxedPassthrough), + Slot::Ciphertext => sealed.next()?, + }; + Ok((key.to_string(), node)) + }) + .collect::<Result<Vec<_>, Error>>()?; + Ok((field.name, CipherText::Map(nodes))) + }) + .collect::<Result<Vec<_>, Error>>()?; + Ok(CipherText::Map(fields)) + }) + .collect::<Result<Vec<_>, Error>>()?; + sealed.finish()?; + + Rows { + rows: row_nodes, + batched, + } + .reshape(CipherText::Sequence) +} + +/// Decrypt a record — or a batch — produced by [`encrypt`] under the same +/// plan. +/// +/// Only the `"c"` outputs participate: terms are one-way. The result is an +/// [`FfiValue::Object`] per record holding the plan's ciphertext-bearing +/// fields, in plan order — or an [`FfiValue::Array`] of them for a batch. +/// One batched `retrieve_keys` per invocation and, when opening through +/// [`Scope::Client`], one per keyset the leaves were sealed under. +/// +/// # Errors +/// +/// [`Error::Record`] if the stored tree does not fit the plan; +/// [`Error::Cipher`] if opening fails — including the expected outcome for +/// a wrong context, a wrong key, a tampered ciphertext, or a leaf from a +/// keyset other than a [`Scope::Keyset`]'s. +pub async fn decrypt<K>( + scope: Scope<'_, K>, + record: StackCipherText, + plan: &Plan, +) -> Result<FfiValue, Error> +where + K: DataKeySource + Sync + 'static, +{ + let Rows { rows, batched } = record_leaves(record, plan)?; + let contexts = plan + .fields + .iter() + .filter(|field| field.has_ciphertext()) + .map(FieldPlan::view) + .collect::<Result<Vec<_>, Error>>()?; + + // Per row, per ciphertext-bearing plan field (in plan order, as + // `record_leaves` lifted them): queue the "c" subtree's decrypt. + let mut pendings: Vec<Pending<'_, FfiValue, K>> = Vec::new(); + let mut names: Vec<Vec<String>> = Vec::with_capacity(rows.len()); + for row in rows { + if row.len() != contexts.len() { + return Err(Error::Internal); + } + let mut row_names = Vec::with_capacity(row.len()); + for ((name, ct), context) in row.into_iter().zip(&contexts) { + let context = context.clone(); + // The scope is the caller's, the declaration is the target's: + // `decrypt_as` takes one context and drives both halves with it. + pendings.push(match &scope { + Scope::Client(cipher) => cipher.decrypt_as(ct, context.into()), + Scope::Keyset(keyset) => keyset.decrypt_as(ct, context.into()), + }); + row_names.push(name); + } + names.push(row_names); + } + + // The one batched key request for the whole invocation. + let mut values = Settled::of(match &scope { + Scope::Client(cipher) => Pending::all(*cipher, pendings).await, + Scope::Keyset(keyset) => Pending::all(keyset, pendings).await, + }?); + + let row_values = names + .into_iter() + .map(|row_names| { + let entries = row_names + .into_iter() + .map(|name| Ok((name, values.next()?))) + .collect::<Result<Vec<_>, Error>>()?; + Ok(FfiValue::Object(entries)) + }) + .collect::<Result<Vec<_>, Error>>()?; + values.finish()?; + + Rows { + rows: row_values, + batched, + } + .reshape(FfiValue::Array) +} + +/// Check a source against a plan without encrypting it — everything +/// [`encrypt`] checks before it consults the cipher. +/// +/// A binding runs this at its boundary so a malformed call fails the same +/// way whether or not a cipher is available, and never costs a keyset load. +/// It is the same parser [`encrypt`] runs, so the two cannot disagree on +/// what is malformed. +/// +/// # Errors +/// +/// As [`encrypt`], minus the cipher. +pub fn check_source(source: FfiValue, plan: &Plan) -> Result<(), Error> { + source_rows(source, plan).map(drop) +} + +/// Check a stored record against a plan without opening it — everything +/// [`decrypt`] checks before it consults the cipher. See [`check_source`]. +/// +/// # Errors +/// +/// As [`decrypt`], minus the cipher. +pub fn check_record(record: StackCipherText, plan: &Plan) -> Result<(), Error> { + record_leaves(record, plan).map(drop) +} + +// ============================================================================= +// The two trees a record path walks +// ============================================================================= + +/// The two trees a record path walks — a source ([`FfiValue`]) and a stored +/// record ([`StackCipherText`]) — seen the one way the path needs to see +/// them: as one row (a map of named nodes) or a sequence of rows, and as a +/// tree that may carry a passthrough somewhere inside it. +trait RecordTree: Sized { + /// The error a tree that does not fit its plan reports. + const MISFIT: Error; + + /// The tree as a row's entries, a batch's rows, or neither. + fn shape(self) -> Shape<Self>; + + /// Whether this node is a passthrough. + fn is_passthrough(&self) -> bool; + + /// The node's children, for a container. + fn children(&self) -> Children<'_, Self>; +} + +/// A tree read as rows. +enum Shape<T> { + /// One row: its named entries. + Row(Vec<(String, T)>), + /// A batch: its rows, each still to be read as one. + Batch(Vec<T>), + /// Neither. + Other, +} + +/// A node's children. +enum Children<'a, T> { + Sequence(&'a [T]), + Map(&'a [(String, T)]), + None, +} + +impl RecordTree for FfiValue { + const MISFIT: Error = Error::Source; + + fn shape(self) -> Shape<Self> { + match self { + FfiValue::Object(entries) => Shape::Row(entries), + FfiValue::Array(items) => Shape::Batch(items), + _ => Shape::Other, + } + } + + fn is_passthrough(&self) -> bool { + matches!(self, FfiValue::Passthrough(_)) + } + + fn children(&self) -> Children<'_, Self> { + match self { + FfiValue::Array(items) => Children::Sequence(items), + FfiValue::Object(entries) => Children::Map(entries), + _ => Children::None, + } + } +} + +impl RecordTree for StackCipherText { + const MISFIT: Error = Error::Record; + + fn shape(self) -> Shape<Self> { + match self { + CipherText::Map(entries) => Shape::Row(entries), + CipherText::Sequence(items) => Shape::Batch(items), + _ => Shape::Other, + } + } + + fn is_passthrough(&self) -> bool { + matches!(self, CipherText::Passthrough(_)) + } + + // Exhaustive, so a variant added to `CipherText` has to say here whether + // it can hide a passthrough. + fn children(&self) -> Children<'_, Self> { + match self { + CipherText::Sequence(items) => Children::Sequence(items), + CipherText::Map(entries) => Children::Map(entries), + CipherText::Passthrough(_) + | CipherText::Single(_) + | CipherText::None(_) + | CipherText::EmptySequence(_) + | CipherText::EmptyMap(_) => Children::None, + } + } +} + +/// The rows of a call — one record, or a batch of them — carried with +/// whether they came as a batch, so the result takes the shape the input +/// had. +struct Rows<T> { + rows: Vec<T>, + batched: bool, +} + +/// Each row of `tree`, as its named entries: one row for a map, one per item +/// for a sequence of maps, and the tree's misfit error for anything else. +fn rows<V: RecordTree>(tree: V) -> Result<Rows<Vec<(String, V)>>, Error> { + match tree.shape() { + Shape::Row(entries) => Ok(Rows { + rows: vec![entries], + batched: false, + }), + Shape::Batch(items) => Ok(Rows { + rows: items + .into_iter() + .map(|item| match item.shape() { + Shape::Row(entries) => Ok(entries), + _ => Err(V::MISFIT), + }) + .collect::<Result<Vec<_>, Error>>()?, + batched: true, + }), + Shape::Other => Err(V::MISFIT), + } +} + +impl<T> Rows<T> { + fn try_map<U>(self, f: impl FnMut(T) -> Result<U, Error>) -> Result<Rows<U>, Error> { + Ok(Rows { + rows: self + .rows + .into_iter() + .map(f) + .collect::<Result<Vec<_>, Error>>()?, + batched: self.batched, + }) + } + + /// The rows in the shape the input had: `batch` over all of them for a + /// batch, the one row bare otherwise. + fn reshape(self, batch: impl FnOnce(Vec<T>) -> T) -> Result<T, Error> { + let Rows { mut rows, batched } = self; + if batched { + Ok(batch(rows)) + } else { + rows.pop().ok_or(Error::Internal) + } + } +} + +/// Take the one entry named `name` out of a row, whatever order the row had +/// it in. `None` if the row has no such entry — or has it twice: the maps +/// this is used on (a row, a field's output map) are stripped here and never +/// reach the cipher's own duplicate-key refusal, so a first-match take would +/// quietly pick one of two `"c"` nodes for a field, and an attacker with +/// write access to the stored tree could append a stale-but-valid +/// ciphertext beside the current one and have it chosen. +fn take<T>(row: &mut Vec<(String, T)>, name: &str) -> Option<(String, T)> { + let mut matches = row.iter().enumerate().filter(|(_, (n, _))| n == name); + let (at, _) = matches.next()?; + if matches.next().is_some() { + return None; + } + Some(row.swap_remove(at)) +} + +/// Whether no key in `entries` repeats. +fn keys_are_unique<T>(entries: &[(String, T)]) -> bool { + let mut seen = std::collections::HashSet::with_capacity(entries.len()); + entries.iter().all(|(key, _)| seen.insert(key.as_str())) +} + +/// Reject a tree that contains a passthrough anywhere, or a map with a key +/// given twice anywhere. +/// +/// The duplicate-key half keeps the preflight honest. The cipher refuses a +/// repeated key itself — at seal, because a map it cannot open must never +/// be produced, and at open, because a stale entry appended beside the +/// current one *verifies* under the same per-entry AAD — but it does so +/// only once the value reaches it: on the encrypt side that is after the +/// plan check has passed, where a failure reads as this module's own bug, +/// and on the decrypt side after the row's keys have been requested. A +/// `check_source`/`check_record` that let such a tree through would say +/// "well-formed" of a value the operation then refuses, so the walk refuses +/// it here, as the misfit it is. +/// +/// On the passthrough half: on the encrypt side a source field value with one inside it must not +/// reach a `"c"` slot: a passthrough node is *unauthenticated by definition* +/// — on decrypt it hands its payload back with no AEAD opened — so admitting +/// one under a field the plan declares ciphertext-bearing would quietly +/// produce a slot whose bytes verify nothing. On the decrypt side a `"c"` +/// subtree with one inside it is the load-bearing half: `decrypt_as` +/// collects **zero** retrieve-requests for a passthrough and returns its +/// payload with no AEAD opened, so an attacker with write access to the +/// stored tree could replace a field's `"c"` subtree with a passthrough +/// carrying forged plaintext, and this check's absence would report it as a +/// successful decrypt. [`encrypt`] never produces a passthrough under `"c"`, +/// so the shape is unconditionally an error, and the encrypt-side check is +/// what makes that a round-trip invariant rather than data loss. +fn check_tree<T: RecordTree>(tree: &T) -> Result<(), Error> { + if tree.is_passthrough() { + return Err(T::MISFIT); + } + match tree.children() { + Children::Sequence(items) => items.iter().try_for_each(check_tree), + Children::Map(entries) => { + if !keys_are_unique(entries) { + return Err(T::MISFIT); + } + entries.iter().try_for_each(|(_, node)| check_tree(node)) + } + Children::None => Ok(()), + } +} + +// ============================================================================= +// Encrypt side +// ============================================================================= + +/// The rows of a record source, each aligned to the plan's field order, with +/// everything that can be checked without a cipher checked: the source is +/// one object or an array of objects, every plan field is present exactly +/// once in every row and no row carries a field the plan does not name +/// (silently dropping a field on either side would lose data or index +/// nothing), and each value fits its field's outputs ([`check_field`]). +fn source_rows(source: FfiValue, plan: &Plan) -> Result<Rows<Vec<FfiValue>>, Error> { + rows(source)?.try_map(|mut row| { + if row.len() != plan.fields.len() { + return Err(Error::Source); + } + plan.fields + .iter() + .map(|field| { + let (_, value) = take(&mut row, &field.name).ok_or(Error::Source)?; + check_field(&value, field)?; + Ok(value) + }) + .collect() + }) +} + +/// A source value against its plan field: every term output needs a scalar +/// the scheme defines the term for ([`TermKind::supports`]), and a +/// ciphertext output refuses a passthrough, or a repeated map key, anywhere +/// in the value ([`check_tree`]). +fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), Error> { + for output in &field.outputs { + match output { + Output::Ciphertext => check_tree(value)?, + Output::Term(kind) => { + let scalar = Scalar::of(value, *kind)?; + if !kind.supports(&scalar) { + return Err(Error::Term { kind: *kind }); + } + } + } + } + Ok(()) +} + +/// One field of a built row: its name and, per output in plan order, the +/// key and what fills it. +struct FieldSkeleton { + name: String, + outputs: Vec<(&'static str, Slot)>, +} + +/// What fills an output slot: a term, derived as the row was built, or the +/// ciphertext still pending in the row's batch, filled in build order once +/// the batch settles. +enum Slot { + Term(Vec<u8>), + Ciphertext, +} + +/// The values a batch settled to, handed back one per slot in build order. +/// The count has to come out exact — a slot with no value, or a value with +/// no slot, means the merge miscounted, which is a bug here. +struct Settled<T>(std::vec::IntoIter<T>); + +impl<T> Settled<T> { + fn of(values: Vec<T>) -> Self { + Self(values.into_iter()) + } + + fn next(&mut self) -> Result<T, Error> { + self.0.next().ok_or(Error::Internal) + } + + fn finish(mut self) -> Result<(), Error> { + match self.0.next() { + Some(_) => Err(Error::Internal), + None => Ok(()), + } + } +} + +/// Build one record row: derive its terms and queue its ciphertext pendings, +/// returning the row skeleton. The plan drives the iteration so the output +/// field order is the plan's; the row arrives from [`source_rows`] already +/// in that order and checked against the plan. +async fn build_row<'c, K>( + cipher: &'c KeysetCipher<'_, K>, + row: Vec<FfiValue>, + plan: &Plan, + pendings: &mut Vec<Pending<'c, StackCipherText, K>>, +) -> Result<Vec<FieldSkeleton>, Error> +where + K: DataKeySource + Sync, +{ + // A row `source_rows` did not align is a bug here, not caller input. + if row.len() != plan.fields.len() { + return Err(Error::Internal); + } + let mut skeleton = Vec::with_capacity(plan.fields.len()); + for (field, value) in plan.fields.iter().zip(row) { + let name = field.name.clone(); + // The one context this field has, cloned per output: this variable + // is what reaches the ciphertext and every term (ADR-0004), and + // there is no other. + let context = field.view()?; + + // Terms first — they lift a copy of the scalar; the value itself is + // consumed by the ciphertext path below. One lift serves every term + // output: the kind only names which error a non-scalar reports. + let scalar = field + .outputs + .iter() + .find_map(|o| match o { + Output::Term(kind) => Some(*kind), + Output::Ciphertext => None, + }) + .map(|kind| Scalar::of(&value, kind)) + .transpose()?; + + let mut outputs = Vec::with_capacity(field.outputs.len()); + for output in &field.outputs { + let Output::Term(kind) = output else { + outputs.push((output.key(), Slot::Ciphertext)); + continue; + }; + let scalar = scalar.clone().ok_or(Error::Internal)?; + outputs.push(( + output.key(), + Slot::Term(term(cipher, scalar, *kind, context.clone()).await?), + )); + } + + if field.has_ciphertext() { + // Re-checked here so this function's own contract does not rest + // on its caller's: with the tree checked, the cipher's refusals + // (a passthrough, a repeated key) cannot fire, and a failure + // below is a bug here. + check_tree(&value)?; + let tree = value + .encrypt_with_aad(cipher, context.clone()) + .map_err(|_| Error::Internal)?; + pendings.push(tree.into_pending(cipher, context)); + } + + skeleton.push(FieldSkeleton { name, outputs }); + } + Ok(skeleton) +} + +// ============================================================================= +// Decrypt side +// ============================================================================= + +/// The `"c"` subtrees a record tree holds for the plan's ciphertext-bearing +/// fields, per row in plan order, with the row's field name: the tree is one +/// map or a sequence of maps, each such field is present exactly once, is a +/// map of outputs with exactly one `"c"` node, and that node has no +/// passthrough and no repeated key in it ([`check_tree`]). Terms and fields +/// the plan does not name are ignored (comparands, not ciphertext). +#[allow(clippy::type_complexity)] +fn record_leaves( + tree: StackCipherText, + plan: &Plan, +) -> Result<Rows<Vec<(String, StackCipherText)>>, Error> { + rows(tree)?.try_map(|mut row| { + plan.fields + .iter() + .filter(|field| field.has_ciphertext()) + .map(|field| { + let (name, node) = take(&mut row, &field.name).ok_or(Error::Record)?; + let Shape::Row(mut outputs) = node.shape() else { + return Err(Error::Record); + }; + let (_, ct) = take(&mut outputs, Output::Ciphertext.key()).ok_or(Error::Record)?; + check_tree(&ct)?; + Ok((name, ct)) + }) + .collect() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::borrow::Cow; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use stack_kms::{ + DataKey, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, IndexKey, + IndexKeySource, RetrieveKeyPayload, UnverifiedContext, + }; + use uuid::Uuid; + use vitaminc_protected::Controlled; + + use crate::dynamic::context; + use crate::{nonempty, StackCipher}; + + /// `FakeDataKeySource` with call counters, so the batching contract — + /// one key request per invocation, none for a refused call — is + /// asserted rather than trusted. + #[derive(Default)] + struct Counting { + inner: FakeDataKeySource, + generate_calls: AtomicUsize, + retrieve_calls: AtomicUsize, + } + + impl DataKeySource for Counting { + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, stack_kms::Error> { + let _ = self.generate_calls.fetch_add(1, Ordering::SeqCst); + self.inner + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, stack_kms::Error> { + let _ = self.retrieve_calls.fetch_add(1, Ordering::SeqCst); + self.inner + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } + } + + impl IndexKeySource for Counting { + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.inner.load_index_key(keyset_id).await + } + } + + async fn cipher() -> StackCipher<Counting> { + StackCipher::builder() + .kms(Counting::default()) + .init() + .await + .expect("build cipher") + } + + fn generates(cipher: &StackCipher<Counting>) -> usize { + cipher.kms().generate_calls.load(Ordering::SeqCst) + } + + fn retrieves(cipher: &StackCipher<Counting>) -> usize { + cipher.kms().retrieve_calls.load(Ordering::SeqCst) + } + + // ---- values, as a binding would decode them ---------------------------- + + fn s(value: &str) -> FfiValue { + FfiValue::String(value.into()) + } + + fn obj(entries: Vec<(&str, FfiValue)>) -> FfiValue { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) + } + + fn strings(items: &[&str]) -> FfiValue { + FfiValue::Array(items.iter().map(|item| s(item)).collect()) + } + + fn spec(context: FfiValue, outputs: &[&str]) -> FfiValue { + obj(vec![("context", context), ("outputs", strings(outputs))]) + } + + /// The plan most tests share: `age` sealed and indexed for equality and + /// order under `"users/age"`; `email` sealed alone under an extended + /// context; `nick` indexed for match only, never sealed. + fn plan_value() -> FfiValue { + obj(vec![ + ("age", spec(s("users/age"), &["c", "eq", "ore"])), + ( + "email", + spec( + FfiValue::Array(vec![s("users/email"), FfiValue::UInt64(7)]), + &["c"], + ), + ), + ("nick", spec(s("users/nick"), &["match"])), + ]) + } + + fn the_plan() -> Plan { + plan(plan_value()).expect("the shared plan parses") + } + + fn row(age: u32) -> FfiValue { + obj(vec![ + ("age", FfiValue::UInt32(age)), + ("email", s("a@x")), + ("nick", s("al smith")), + ]) + } + + // ---- reading results back ----------------------------------------------- + + fn map(tree: StackCipherText) -> Vec<(String, StackCipherText)> { + match tree { + CipherText::Map(entries) => entries, + _ => panic!("expected a map node"), + } + } + + fn sequence(tree: StackCipherText) -> Vec<StackCipherText> { + match tree { + CipherText::Sequence(items) => items, + _ => panic!("expected a sequence node"), + } + } + + fn object(value: FfiValue) -> Vec<(String, FfiValue)> { + match value { + FfiValue::Object(entries) => entries, + _ => panic!("expected an object"), + } + } + + fn array(value: FfiValue) -> Vec<FfiValue> { + match value { + FfiValue::Array(items) => items, + _ => panic!("expected an array"), + } + } + + fn keys<T>(entries: &[(String, T)]) -> Vec<&str> { + entries.iter().map(|(k, _)| k.as_str()).collect() + } + + fn node(entries: &mut Vec<(String, StackCipherText)>, key: &str) -> StackCipherText { + take(entries, key) + .unwrap_or_else(|| panic!("no {key} node")) + .1 + } + + fn term_bytes(node: &StackCipherText) -> Vec<u8> { + match node { + CipherText::Passthrough(payload) => match (**payload).downcast_ref::<FfiValue>() { + Some(FfiValue::Bytes(bytes)) => bytes.risky_ref().to_vec(), + _ => panic!("a term rides as a passthrough byte node"), + }, + _ => panic!("a term rides as a passthrough"), + } + } + + fn u32_of(value: &FfiValue) -> u32 { + match value { + FfiValue::UInt32(v) => *v, + _ => panic!("expected a u32"), + } + } + + fn text_of(value: &FfiValue) -> String { + match value { + FfiValue::String(s) => String::from_utf8(s.risky_ref().to_vec()).expect("utf8"), + _ => panic!("expected text"), + } + } + + fn forged(value: FfiValue) -> StackCipherText { + CipherText::Passthrough(Box::new(value) as BoxedPassthrough) + } + + /// `Settled` is exact both ways: a slot with no value and a value with + /// no slot are both the merge miscounting, reported as `Internal`. + #[test] + fn settled_values_must_match_their_slots_exactly() { + let mut settled = Settled::of(vec![1]); + assert!(matches!(settled.next(), Ok(1))); + assert!( + matches!(settled.next(), Err(Error::Internal)), + "a slot with no value" + ); + assert!(Settled::of(Vec::<u8>::new()).finish().is_ok()); + assert!( + matches!(Settled::of(vec![1]).finish(), Err(Error::Internal)), + "a value with no slot" + ); + } + + /// A table row: what is refused, the value that must be refused, and + /// the error it must be refused with. `Error` is not `PartialEq`, so the + /// expectation is a predicate. + type Refused = (&'static str, FfiValue, fn(&Error) -> bool); + + mod given_a_plan_value { + use super::*; + + #[test] + fn parses_each_field_in_order_with_its_context_and_outputs() { + let plan = the_plan(); + assert_eq!( + plan.fields() + .iter() + .map(FieldPlan::name) + .collect::<Vec<_>>(), + ["age", "email", "nick"], + "fields keep the plan's order" + ); + assert_eq!( + plan.fields()[0].outputs(), + [ + Output::Ciphertext, + Output::Term(TermKind::Equality), + Output::Term(TermKind::Ore) + ], + "outputs keep their spelled order" + ); + assert_eq!( + plan.fields()[1].outputs(), + [Output::Ciphertext], + "a field can be sealed alone" + ); + assert_eq!( + plan.fields()[2].outputs(), + [Output::Term(TermKind::Match)], + "a field can be indexed and never sealed" + ); + assert!( + plan.fields()[0].has_ciphertext() + && plan.fields()[1].has_ciphertext() + && !plan.fields()[2].has_ciphertext(), + "has_ciphertext follows the outputs" + ); + assert_eq!( + plan.fields()[1].context(), + &context(FfiValue::Array(vec![s("users/email"), FfiValue::UInt64(7)])) + .expect("context"), + "a field's context is the one its spec spelled, read by `context`" + ); + } + + #[test] + fn refuses_a_malformed_plan_before_any_field_is_built() { + let cases: Vec<Refused> = vec![ + ("a plan that is not an object", s("x"), |e| { + matches!(e, Error::Plan) + }), + ("an empty plan", obj(vec![]), |e| matches!(e, Error::Plan)), + ( + "a field spec that is not an object", + obj(vec![("age", s("x"))]), + |e| matches!(e, Error::Plan), + ), + ( + "a field spec with an unknown key", + obj(vec![( + "age", + obj(vec![ + ("context", s("users/age")), + ("outputs", strings(&["c"])), + ("nullable", FfiValue::Bool(true)), + ]), + )]), + |e| matches!(e, Error::Plan), + ), + ( + "a field spec with no context", + obj(vec![("age", obj(vec![("outputs", strings(&["c"]))]))]), + |e| matches!(e, Error::Plan), + ), + ( + "a field spec with no outputs", + obj(vec![("age", obj(vec![("context", s("users/age"))]))]), + |e| matches!(e, Error::Plan), + ), + ( + "outputs that are not a list", + obj(vec![("age", spec(s("users/age"), &[]))]), + |e| matches!(e, Error::Plan), + ), + ( + "an output that is not a string", + obj(vec![( + "age", + obj(vec![ + ("context", s("users/age")), + ("outputs", FfiValue::Array(vec![FfiValue::UInt32(1)])), + ]), + )]), + |e| matches!(e, Error::Plan), + ), + ( + "an unknown output", + obj(vec![("age", spec(s("users/age"), &["c", "sum"]))]), + |e| matches!(e, Error::Plan), + ), + ( + "an output named twice", + obj(vec![("age", spec(s("users/age"), &["c", "eq", "c"]))]), + |e| matches!(e, Error::Plan), + ), + ( + "a field named twice", + FfiValue::Object(vec![ + ("age".to_string(), spec(s("users/age"), &["c"])), + ("age".to_string(), spec(s("users/age"), &["eq"])), + ]), + |e| matches!(e, Error::Plan), + ), + ( + "a context given twice", + obj(vec![( + "age", + obj(vec![ + ("context", s("users/age")), + ("outputs", strings(&["c"])), + ("context", s("users/other")), + ]), + )]), + |e| matches!(e, Error::Plan), + ), + ( + "outputs given twice", + obj(vec![( + "age", + obj(vec![ + ("context", s("users/age")), + ("outputs", strings(&["c"])), + ("outputs", strings(&["eq"])), + ]), + )]), + |e| matches!(e, Error::Plan), + ), + ( + "a context that is not one", + obj(vec![("age", spec(FfiValue::Bool(true), &["c"]))]), + |e| matches!(e, Error::Context), + ), + ( + "a context that renders empty", + obj(vec![("age", spec(s(""), &["c"]))]), + |e| matches!(e, Error::Context), + ), + ]; + for (label, value, expected) in cases { + let err = plan(value).err(); + assert!( + err.as_ref().is_some_and(expected), + "{label} must be refused as the right error: {err:?}" + ); + } + } + + #[test] + fn a_field_plan_refuses_no_outputs_and_a_repeated_output() { + let ctx = context(s("users/age")).expect("context"); + assert!( + matches!(FieldPlan::new("age", ctx.clone(), vec![]), Err(Error::Plan)), + "a field must produce something" + ); + assert!( + matches!( + FieldPlan::new( + "age", + ctx.clone(), + vec![Output::Term(TermKind::Ore), Output::Term(TermKind::Ore)] + ), + Err(Error::Plan) + ), + "an output cannot be produced twice under one key" + ); + assert!( + FieldPlan::new("age", ctx, vec![Output::Ciphertext]).is_ok(), + "one output is a plan" + ); + } + + /// The whole-plan rules hold for a plan built by hand, not only for + /// a parsed one: a hand-built plan reaches the same `encrypt` and + /// `check_source`, which rely on them. + #[test] + fn a_hand_built_plan_refuses_no_fields_and_a_repeated_name() { + let field = |name: &str| { + FieldPlan::new( + name, + context(s("users/age")).expect("context"), + vec![Output::Ciphertext], + ) + .expect("field") + }; + assert!( + matches!(Plan::new(vec![]), Err(Error::Plan)), + "a plan must have a field" + ); + assert!( + matches!( + Plan::new(vec![field("age"), field("age")]), + Err(Error::Plan) + ), + "a field cannot be planned twice" + ); + let plan = Plan::new(vec![field("age"), field("email")]).expect("a plan"); + assert_eq!( + plan.fields() + .iter() + .map(FieldPlan::name) + .collect::<Vec<_>>(), + ["age", "email"], + "the fields keep the order given" + ); + } + } + + mod given_a_source_that_does_not_fit_the_plan { + use super::*; + + /// `check_source` is the parser `encrypt` runs, so a binding's + /// boundary rejection and the operation's are the same error — and + /// neither costs a key request. + #[tokio::test] + async fn encrypt_and_check_source_refuse_it_alike_with_no_key_request() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + let mut with_passthrough_in_a_list = object(row(1)); + with_passthrough_in_a_list[1].1 = + FfiValue::Array(vec![s("a@x"), FfiValue::Passthrough(Box::new(s("b@x")))]); + let cases: Vec<Refused> = vec![ + ("a source that is not an object", FfiValue::UInt32(1), |e| { + matches!(e, Error::Source) + }), + ( + "a batch with an item that is not an object", + FfiValue::Array(vec![row(1), FfiValue::UInt32(2)]), + |e| matches!(e, Error::Source), + ), + ( + "a row missing a plan field", + obj(vec![("age", FfiValue::UInt32(1)), ("email", s("a@x"))]), + |e| matches!(e, Error::Source), + ), + ( + "a row with a field the plan does not name", + { + let mut entries = object(row(1)); + entries.push(("extra".to_string(), s("x"))); + FfiValue::Object(entries) + }, + |e| matches!(e, Error::Source), + ), + ( + "a passthrough under a sealed field", + { + let mut entries = object(row(1)); + entries[1].1 = FfiValue::Passthrough(Box::new(s("a@x"))); + FfiValue::Object(entries) + }, + |e| matches!(e, Error::Source), + ), + ( + "a passthrough inside a list under a sealed field", + FfiValue::Object(with_passthrough_in_a_list), + |e| matches!(e, Error::Source), + ), + ( + "a plan field given twice", + { + let mut entries = object(row(1)); + let _ = take(&mut entries, "nick"); + entries.push(("age".to_string(), FfiValue::UInt32(2))); + FfiValue::Object(entries) + }, + |e| matches!(e, Error::Source), + ), + ( + "a repeated key inside an object under a sealed field", + { + let mut entries = object(row(1)); + entries[1].1 = obj(vec![("k", s("a@x")), ("k", s("b@x"))]); + FfiValue::Object(entries) + }, + |e| matches!(e, Error::Source), + ), + ( + "a container under an indexed field", + { + let mut entries = object(row(1)); + entries[2].1 = FfiValue::Array(vec![s("al")]); + FfiValue::Object(entries) + }, + |e| { + matches!( + e, + Error::Term { + kind: TermKind::Match + } + ) + }, + ), + ( + "a scalar the scheme has no such term for", + { + let mut entries = object(row(1)); + entries[2].1 = FfiValue::UInt32(3); + FfiValue::Object(entries) + }, + |e| { + matches!( + e, + Error::Term { + kind: TermKind::Match + } + ) + }, + ), + ]; + // A value is consumed by the call that checks it, so the table + // exercises the boundary parser and the operation is exercised + // below on the shapes a caller is likeliest to get wrong. + for (label, source, expected) in cases { + let err = check_source(source, &plan).err(); + assert!( + err.as_ref().is_some_and(expected), + "{label}: check_source must refuse it as the right error: {err:?}" + ); + } + let missing = obj(vec![("age", FfiValue::UInt32(1)), ("email", s("a@x"))]); + let err = encrypt(&keyset, missing, &plan).await.err(); + assert!( + matches!(err, Some(Error::Source)), + "encrypt refuses a row missing a plan field: {err:?}" + ); + let mut entries = object(row(1)); + entries[1].1 = FfiValue::Passthrough(Box::new(s("a@x"))); + let err = encrypt(&keyset, FfiValue::Object(entries), &plan) + .await + .err(); + assert!( + matches!(err, Some(Error::Source)), + "encrypt refuses a passthrough under a sealed field: {err:?}" + ); + let mut entries = object(row(1)); + entries[2].1 = FfiValue::UInt32(3); + let err = encrypt(&keyset, FfiValue::Object(entries), &plan) + .await + .err(); + assert!( + matches!( + err, + Some(Error::Term { + kind: TermKind::Match + }) + ), + "encrypt refuses a value with no such term: {err:?}" + ); + assert_eq!( + generates(&cipher), + 0, + "a refused source costs no key request" + ); + } + + #[tokio::test] + async fn a_float_asked_for_equality_is_refused_as_that_kind() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = + plan(obj(vec![("score", spec(s("users/score"), &["c", "eq"]))])).expect("plan"); + let source = obj(vec![("score", FfiValue::Float64(1.5))]); + let err = encrypt(&keyset, source, &plan).await.err(); + assert!( + matches!( + err, + Some(Error::Term { + kind: TermKind::Equality + }) + ), + "no PRF encoding exists for a float: {err:?}" + ); + assert_eq!(generates(&cipher), 0, "refused before any key request"); + } + } + + mod given_one_record { + use super::*; + + #[tokio::test] + async fn seals_it_from_one_key_request_in_the_plan_shape() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + + let sealed = encrypt(&keyset, row(34), &plan).await.expect("encrypt"); + assert_eq!( + generates(&cipher), + 1, + "every ciphertext leaf seals from one batched key request" + ); + + let mut fields = map(sealed); + assert_eq!( + keys(&fields), + ["age", "email", "nick"], + "the result holds every plan field, in plan order" + ); + let mut age = map(node(&mut fields, "age")); + assert_eq!( + keys(&age), + ["c", "eq", "ore"], + "a field's outputs ride under their keys, in output order" + ); + assert!( + !matches!(node(&mut age, "c"), CipherText::Passthrough(_)), + "the ciphertext is never a passthrough" + ); + assert_eq!( + term_bytes(&node(&mut age, "eq")).len(), + 32, + "an equality term is the raw 32 PRF bytes" + ); + let email = map(node(&mut fields, "email")); + assert_eq!( + keys(&email), + ["c"], + "a sealed-only field has just its ciphertext" + ); + let nick = map(node(&mut fields, "nick")); + assert_eq!( + keys(&nick), + ["match"], + "an indexed-only field has just its term" + ); + } + + /// ADR-0004's property, pinned: the ciphertext opens under the plan + /// context and under nothing else, and each term is the standalone + /// derivation under that same context. + #[tokio::test] + async fn binds_the_ciphertext_and_every_term_under_the_one_plan_context() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + let age_ctx = plan.fields()[0].context().clone(); + let nick_ctx = plan.fields()[2].context().clone(); + + let mut fields = map(encrypt(&keyset, row(34), &plan).await.expect("encrypt")); + let mut age = map(node(&mut fields, "age")); + let mut nick = map(node(&mut fields, "nick")); + + let opened: FfiValue = cipher + .decrypt(node(&mut age, "c"), age_ctx.clone()) + .await + .expect("the ciphertext opens under the plan context"); + assert_eq!(u32_of(&opened), 34, "and to the value that was sealed"); + + let mut email = map(node(&mut fields, "email")); + let wrong: Result<FfiValue, _> = + cipher.decrypt(node(&mut email, "c"), age_ctx.clone()).await; + assert!( + wrong.is_err(), + "a field's ciphertext does not open under another field's context" + ); + + let eq = term( + &keyset, + Scalar::U32(34), + TermKind::Equality, + age_ctx.clone(), + ) + .await + .expect("standalone equality term"); + assert_eq!( + term_bytes(&node(&mut age, "eq")), + eq, + "the equality term is the standalone derivation under the plan context" + ); + let ore = term(&keyset, Scalar::U32(34), TermKind::Ore, age_ctx) + .await + .expect("standalone ore term"); + assert_eq!( + term_bytes(&node(&mut age, "ore")), + ore, + "the ore term is the standalone derivation under the plan context" + ); + let scalar = Scalar::of(&s("al smith"), TermKind::Match).expect("text"); + let matched = term(&keyset, scalar, TermKind::Match, nick_ctx) + .await + .expect("standalone match term"); + assert_eq!( + term_bytes(&node(&mut nick, "match")), + matched, + "the match term is the standalone derivation under the plan context" + ); + } + + #[tokio::test] + async fn opens_back_to_its_ciphertext_bearing_fields_in_plan_order() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + let sealed = encrypt(&keyset, row(34), &plan).await.expect("encrypt"); + + let opened = decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("decrypt"); + assert_eq!( + retrieves(&cipher), + 1, + "every ciphertext leaf opens from one batched key request" + ); + let fields = object(opened); + assert_eq!( + keys(&fields), + ["age", "email"], + "only the sealed fields come back, in plan order; terms are one-way" + ); + assert_eq!(u32_of(&fields[0].1), 34, "the age round-trips"); + assert_eq!(text_of(&fields[1].1), "a@x", "the email round-trips"); + + // Through the keyset it was sealed under, too. + let sealed = encrypt(&keyset, row(35), &plan).await.expect("encrypt"); + let fields = object( + decrypt(Scope::Keyset(keyset.clone()), sealed, &plan) + .await + .expect("decrypt through the keyset"), + ); + assert_eq!( + u32_of(&fields[0].1), + 35, + "the age round-trips through its keyset" + ); + } + + /// A sealed field can hold a whole object. The walk refuses a key + /// given *twice*; an object whose keys are all distinct is + /// well-formed on both sides of the round trip. + #[tokio::test] + async fn a_nested_object_with_distinct_keys_round_trips() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + let source = || { + let mut entries = object(row(34)); + entries[1].1 = obj(vec![("home", s("a@x")), ("work", s("b@x"))]); + FfiValue::Object(entries) + }; + + check_source(source(), &plan).expect("check_source accepts it"); + let sealed = encrypt(&keyset, source(), &plan).await.expect("encrypt"); + check_record(sealed, &plan).expect("check_record accepts it"); + + let sealed = encrypt(&keyset, source(), &plan).await.expect("encrypt"); + let fields = object( + decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("decrypt"), + ); + let email = object(fields.into_iter().nth(1).expect("the email field").1); + assert_eq!(keys(&email), ["home", "work"]); + assert_eq!(text_of(&email[0].1), "a@x"); + assert_eq!(text_of(&email[1].1), "b@x"); + } + } + + mod given_a_batch { + use super::*; + + #[tokio::test] + async fn seals_every_row_from_one_key_request_and_opens_as_an_array() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + + let sealed = encrypt( + &keyset, + FfiValue::Array(vec![row(1), row(2), row(3)]), + &plan, + ) + .await + .expect("encrypt"); + assert_eq!(generates(&cipher), 1, "one key request for the whole batch"); + + let rows = sequence(sealed); + assert_eq!(rows.len(), 3, "a batch seals to a sequence of rows"); + let opened = decrypt(Scope::Client(&cipher), CipherText::Sequence(rows), &plan) + .await + .expect("decrypt"); + assert_eq!( + retrieves(&cipher), + 1, + "one key request to open the whole batch" + ); + let rows = array(opened); + assert_eq!( + rows.into_iter() + .map(|row| u32_of(&object(row)[0].1)) + .collect::<Vec<_>>(), + [1, 2, 3], + "rows open in the order they were sealed" + ); + } + + #[tokio::test] + async fn an_empty_batch_is_an_empty_batch() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + let sealed = encrypt(&keyset, FfiValue::Array(vec![]), &plan) + .await + .expect("an empty batch seals"); + assert!( + matches!(&sealed, CipherText::Sequence(rows) if rows.is_empty()), + "an empty batch seals to an empty sequence" + ); + let opened = decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("an empty batch opens"); + assert!( + array(opened).is_empty(), + "an empty sequence opens to an empty array" + ); + assert_eq!( + (generates(&cipher), retrieves(&cipher)), + (0, 0), + "nothing to seal or open costs no key request" + ); + } + } + + mod given_a_stored_record_that_does_not_fit_the_plan { + use super::*; + + async fn sealed(keyset: &KeysetCipher<'_, Counting>) -> Vec<(String, StackCipherText)> { + map(encrypt(keyset, row(34), &the_plan()) + .await + .expect("encrypt")) + } + + /// The forged-plaintext case the module docs call load-bearing is + /// in here: a passthrough under `"c"` must be refused, because + /// `decrypt_as` would otherwise hand its payload back as if opened. + #[tokio::test] + async fn decrypt_and_check_record_refuse_it_before_any_key_is_retrieved() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + + let mut cases: Vec<(&str, StackCipherText)> = Vec::new(); + + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + cases.push(("a record that is not a map", node(&mut age, "c"))); + + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + cases.push(( + "a batch with a row that is not a map", + CipherText::Sequence(vec![node(&mut age, "c")]), + )); + + let mut fields = sealed(&keyset).await; + let _ = node(&mut fields, "email"); + cases.push(("a record missing a sealed field", CipherText::Map(fields))); + + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + fields.push(("age".to_string(), node(&mut age, "c"))); + cases.push(( + "a sealed field that is not an output map", + CipherText::Map(fields), + )); + + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let _ = node(&mut age, "c"); + fields.push(("age".to_string(), CipherText::Map(age))); + cases.push(( + "a sealed field with no ciphertext output", + CipherText::Map(fields), + )); + + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let _ = node(&mut age, "c"); + age.push(("c".to_string(), forged(FfiValue::UInt32(99)))); + fields.push(("age".to_string(), CipherText::Map(age))); + cases.push(( + "a passthrough where the ciphertext should be", + CipherText::Map(fields), + )); + + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let _ = node(&mut age, "c"); + age.push(( + "c".to_string(), + CipherText::Map(vec![("v".to_string(), forged(FfiValue::UInt32(99)))]), + )); + fields.push(("age".to_string(), CipherText::Map(age))); + cases.push(( + "a passthrough inside the ciphertext subtree", + CipherText::Map(fields), + )); + + // The three shapes where a first-match take would have picked + // one of two valid ciphertexts: a second, stale-but-valid copy + // of a field, of its `"c"` output, or of a key inside it. + let mut fields = sealed(&keyset).await; + let mut stale = sealed(&keyset).await; + fields.push(("age".to_string(), node(&mut stale, "age"))); + cases.push(("a sealed field given twice", CipherText::Map(fields))); + + let mut fields = sealed(&keyset).await; + let mut stale = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let mut stale_age = map(node(&mut stale, "age")); + age.push(("c".to_string(), node(&mut stale_age, "c"))); + fields.push(("age".to_string(), CipherText::Map(age))); + cases.push(("a ciphertext output given twice", CipherText::Map(fields))); + + let mut fields = sealed(&keyset).await; + let mut stale = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let mut stale_age = map(node(&mut stale, "age")); + let (current, older) = (node(&mut age, "c"), node(&mut stale_age, "c")); + age.push(( + "c".to_string(), + CipherText::Map(vec![("v".to_string(), current), ("v".to_string(), older)]), + )); + fields.push(("age".to_string(), CipherText::Map(age))); + cases.push(( + "a repeated key inside the ciphertext subtree", + CipherText::Map(fields), + )); + + let before = retrieves(&cipher); + for (label, record) in cases { + let err = decrypt(Scope::Client(&cipher), record, &plan).await.err(); + assert!( + matches!(err, Some(Error::Record)), + "{label}: decrypt must refuse it as a misfit record: {err:?}" + ); + } + assert_eq!( + retrieves(&cipher), + before, + "a misfit record is refused before any key is retrieved" + ); + + // And the boundary parser agrees, on the load-bearing shape. + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let _ = node(&mut age, "c"); + age.push(("c".to_string(), forged(FfiValue::UInt32(99)))); + fields.push(("age".to_string(), CipherText::Map(age))); + let err = check_record(CipherText::Map(fields), &plan).err(); + assert!( + matches!(err, Some(Error::Record)), + "check_record refuses a forged ciphertext the same way: {err:?}" + ); + let mut fields = sealed(&keyset).await; + let mut stale = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let mut stale_age = map(node(&mut stale, "age")); + age.push(("c".to_string(), node(&mut stale_age, "c"))); + fields.push(("age".to_string(), CipherText::Map(age))); + let err = check_record(CipherText::Map(fields), &plan).err(); + assert!( + matches!(err, Some(Error::Record)), + "check_record refuses a twice-given ciphertext the same way: {err:?}" + ); + } + + #[tokio::test] + async fn ignores_terms_and_entries_the_plan_does_not_open() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + + let mut fields = sealed(&keyset).await; + // The indexed-only field can be absent, a term can be anything, + // and an entry the plan does not name is not looked at. + let _ = node(&mut fields, "nick"); + let mut age = map(node(&mut fields, "age")); + let _ = node(&mut age, "eq"); + age.push(("eq".to_string(), forged(s("not a term")))); + age.push(("zzz".to_string(), forged(s("not an output")))); + fields.push(("age".to_string(), CipherText::Map(age))); + fields.push(("extra".to_string(), forged(s("not a field")))); + + let opened = object( + decrypt(Scope::Client(&cipher), CipherText::Map(fields), &plan) + .await + .expect("decrypt"), + ); + assert_eq!(keys(&opened), ["age", "email"], "the sealed fields open"); + assert_eq!(u32_of(&opened[0].1), 34, "to what was sealed"); + } + } + + mod given_a_keyset_scope { + use super::*; + + fn named(name: &str) -> IdentifiedBy { + IdentifiedBy::Name(name.to_string().into()) + } + + #[tokio::test] + async fn refuses_a_leaf_from_another_keyset_before_any_key_is_retrieved() { + let cipher = cipher().await; + let acme = cipher.keyset(named("acme")).await.expect("acme"); + let globex = cipher.keyset(named("globex")).await.expect("globex"); + let plan = the_plan(); + + let sealed = encrypt(&acme, row(34), &plan).await.expect("encrypt"); + let err = decrypt(Scope::Keyset(globex.clone()), sealed, &plan) + .await + .err(); + assert!( + matches!( + err, + Some(Error::Cipher(crate::Error::ForeignKeyset { expected, found })) + if expected == globex.keyset_id() && found == acme.keyset_id() + ), + "another tenant's keyset refuses the leaf, naming both keysets: {err:?}" + ); + assert_eq!( + retrieves(&cipher), + 0, + "refused before any key was retrieved" + ); + + let sealed = encrypt(&acme, row(34), &plan).await.expect("encrypt"); + let opened = object( + decrypt(Scope::Keyset(acme.clone()), sealed, &plan) + .await + .expect("its own keyset opens it"), + ); + assert_eq!(u32_of(&opened[0].1), 34, "to what was sealed"); + + let sealed = encrypt(&acme, row(34), &plan).await.expect("encrypt"); + let opened = object( + decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("the client opens a leaf from any of its keysets"), + ); + assert_eq!(u32_of(&opened[0].1), 34, "to what was sealed"); + } + + #[tokio::test] + async fn debug_names_the_cipher_it_opens_through() { + let cipher = cipher().await; + assert!( + format!("{:?}", Scope::Client(&cipher)).starts_with("Client("), + "the client scope says so" + ); + assert!( + format!("{:?}", Scope::Keyset(cipher.default_keyset())).starts_with("Keyset("), + "the keyset scope says so" + ); + } + } + + /// The typed helper the tests lean on, pinned in passing: `nonempty!` + /// and `context` agree, so a test written against either is the same + /// test. + #[test] + fn the_plan_context_is_the_typed_context() { + use crate::IntoAad; + assert_eq!( + the_plan().fields()[0] + .context() + .clone() + .into_inner() + .into_aad() + .as_bytes(), + nonempty!("users/age").into_aad().as_bytes(), + "a bare plan string is the typed literal" + ); + } +} diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs new file mode 100644 index 000000000..4b4691701 --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -0,0 +1,625 @@ +//! One index term for a runtime value. +//! +//! The dispatch here is the whole point of the module: which arm a value +//! takes decides the term's PRF/CLLW *input encoding*, and that encoding is +//! part of the cross-language contract. An equality term for +//! `FfiValue::UInt32(34)` must equal the term the typed path derives for +//! `34u32`, so each variant is handed to the same typed operation a Rust +//! caller would have named. +//! +//! # These are the query-probe path +//! +//! Same caveat as [`crate::sem`]: a term derived here is bound to the +//! context you pass and to nothing else. Use it to *query*. A term that is +//! going to be **stored** should come from the record path, where it shares +//! one context with the ciphertext beside it (ADR-0004). + +use std::fmt; + +use stack_kms::DataKeySource; +use vitaminc_aead_value::FfiValue; +use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; +use zeroize::Zeroizing; + +use super::{utf8, Error}; +use crate::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; +use crate::{IntoPrfContext, KeysetCipher, NonEmpty}; + +/// Which index term to derive. +/// +/// The `key` strings are wire format, and that is why this enum is +/// exhaustive — see the [module docs](super#stability). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum TermKind { + /// `"eq"` — equality (exact match). Raw 32 PRF bytes. + Equality, + /// `"match"` — full-text match under the default tokenizer config. LE + /// `u16` bit positions. + Match, + /// `"ore"` — order-revealing comparison. Raw CLLW bytes. + Ore, + /// `"ope"` — order-preserving comparison. Raw CLLW bytes. + Ope, +} + +impl TermKind { + /// The map key this term rides under in a record, and the string a + /// binding spells it as. + pub fn key(self) -> &'static str { + match self { + TermKind::Equality => "eq", + TermKind::Match => "match", + TermKind::Ore => "ore", + TermKind::Ope => "ope", + } + } + + /// Whether the scheme defines this term for `scalar`. + /// + /// No PRF encoding exists for floats (equality on IEEE-754 values is a + /// modelling error) or booleans; match is text-only; the ordering + /// schemes take every scalar. This is the one table — [`term`]'s arms + /// mirror it and are unreachable for a pair it refuses — and it is + /// consulted before any cipher work, so a binding can reject a bad + /// request at its boundary without minting anything. + pub fn supports(self, scalar: &Scalar) -> bool { + match self { + TermKind::Equality => { + !matches!(scalar, Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_)) + } + TermKind::Match => matches!(scalar, Scalar::Text(_)), + TermKind::Ore | TermKind::Ope => true, + } + } +} + +impl fmt::Display for TermKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.key()) + } +} + +/// A term-able scalar lifted out of an [`FfiValue`] leaf. +/// +/// Lifting is a copy, so the value it came from stays movable into the +/// ciphertext path beside it. The owned text and bytes copies wipe on drop; +/// the PRF and CLLW layers move them into [`Protected`] internally. +/// +/// It is plaintext, so its `Debug` is opaque: the variant is named, the +/// value is masked. +#[derive(Clone, OpaqueDebug)] +#[non_exhaustive] +pub enum Scalar { + /// From [`FfiValue::Bool`]. + Bool(bool), + /// From [`FfiValue::Int32`]. + I32(i32), + /// From [`FfiValue::Int64`]. + I64(i64), + /// From [`FfiValue::UInt32`]. + U32(u32), + /// From [`FfiValue::UInt64`]. + U64(u64), + /// From [`FfiValue::Float32`]. + F32(f32), + /// From [`FfiValue::Float64`]. + F64(f64), + /// From [`FfiValue::String`]. + Text(Zeroizing<String>), + /// From [`FfiValue::Bytes`]. + Bytes(Zeroizing<Vec<u8>>), +} + +impl Scalar { + /// Lift the scalar out of a value leaf. + /// + /// # Errors + /// + /// [`Error::Term`] for a container, null, undefined or passthrough: + /// those have no term semantics at all, whatever the kind. `kind` names + /// the term the caller was after, for the error only — whether that kind + /// is defined for the scalar is [`TermKind::supports`]. + pub fn of(value: &FfiValue, kind: TermKind) -> Result<Self, Error> { + Ok(match value { + FfiValue::Bool(v) => Scalar::Bool(*v), + FfiValue::Int32(v) => Scalar::I32(*v), + FfiValue::Int64(v) => Scalar::I64(*v), + FfiValue::UInt32(v) => Scalar::U32(*v), + FfiValue::UInt64(v) => Scalar::U64(*v), + FfiValue::Float32(v) => Scalar::F32(*v), + FfiValue::Float64(v) => Scalar::F64(*v), + FfiValue::String(s) => Scalar::Text(Zeroizing::new( + utf8(s).ok_or(Error::Term { kind })?.to_string(), + )), + FfiValue::Bytes(b) => Scalar::Bytes(Zeroizing::new(b.risky_ref().to_vec())), + // Containers, nulls and passthroughs have no term semantics. + _ => return Err(Error::Term { kind }), + }) + } +} + +/// Derive one index term's frozen byte encoding. +/// +/// # Examples +/// +/// A probe for a value a binding decoded derives the bytes the typed path +/// derives for the same value under the same context: +/// +/// ``` +/// use stack_encrypt::dynamic::{context, term, FfiValue, Scalar, TermKind}; +/// use stack_encrypt::StackCipher; +/// use stack_kms::FakeDataKeySource; +/// +/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +/// let cipher = StackCipher::builder() +/// .kms(FakeDataKeySource::new()) +/// .init() +/// .await?; +/// let keyset = cipher.default_keyset(); +/// +/// let ctx = context(FfiValue::String("users/age".into()))?; +/// let probe = term(&keyset, Scalar::U32(34), TermKind::Equality, ctx.clone()).await?; +/// let typed = keyset.equality_term(34u32, ctx).await?; +/// assert_eq!(probe, typed.into_bytes().to_vec()); +/// # Ok::<(), stack_encrypt::dynamic::Error>(()) +/// # }).unwrap(); +/// ``` +/// +/// # Errors +/// +/// [`Error::Term`] if the scheme defines no such term for the scalar +/// ([`TermKind::supports`] is the table, and checking it first is how a +/// binding turns this into a boundary rejection). [`Error::Cipher`] if the +/// derivation itself fails. +pub async fn term<'c, K, D>( + cipher: &KeysetCipher<'_, K>, + scalar: Scalar, + kind: TermKind, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + D: IntoPrfContext<'c>, +{ + match kind { + TermKind::Equality => equality(cipher, scalar, context).await, + TermKind::Match => match_term(cipher, scalar, context).await, + TermKind::Ore => ore_of(cipher, scalar, context).await, + TermKind::Ope => ope_of(cipher, scalar, context).await, + } +} + +/// [`TermKind::Equality`] per scalar: one PRF block over the value, for +/// every integer width, text and bytes. +async fn equality<'c, K, D>( + cipher: &KeysetCipher<'_, K>, + scalar: Scalar, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + D: IntoPrfContext<'c>, +{ + let term = match scalar { + Scalar::I32(v) => cipher.equality_term(v, context).await, + Scalar::I64(v) => cipher.equality_term(v, context).await, + Scalar::U32(v) => cipher.equality_term(v, context).await, + Scalar::U64(v) => cipher.equality_term(v, context).await, + Scalar::Text(t) => cipher.equality_term(String::clone(&t), context).await, + Scalar::Bytes(b) => { + cipher + .equality_term(Protected::new(Vec::clone(&b)), context) + .await + } + // No PRF encoding is defined for floats (equality on IEEE-754 + // values is a modelling error) or booleans. + Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_) => { + return Err(Error::Term { + kind: TermKind::Equality, + }) + } + }?; + Ok(term.into_bytes().to_vec()) +} + +/// [`TermKind::Match`] per scalar: text only. +async fn match_term<'c, K, D>( + cipher: &KeysetCipher<'_, K>, + scalar: Scalar, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + D: IntoPrfContext<'c>, +{ + match scalar { + Scalar::Text(t) => Ok(cipher + .match_terms::<DefaultMatch>(&t, context) + .await + .map(|t| t.to_bytes())?), + _ => Err(Error::Term { + kind: TermKind::Match, + }), + } +} + +/// [`TermKind::Ore`] per scalar: every scalar has an ORE encoding. +/// +/// The text and bytes arms hand the encryptor the `Zeroizing` operand +/// itself, not a bare clone of its contents: the CLLW encryptors take +/// their value by `'static` ownership (the visitor carries it), so a +/// cloned-out `String`/`Vec<u8>` would be freed with the plaintext +/// still in it — in a guest's linear memory, where the host can read +/// it. Keeping the wrapper costs nothing and saves the copy as well. +async fn ore_of<'c, K, D>( + cipher: &KeysetCipher<'_, K>, + scalar: Scalar, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + D: IntoPrfContext<'c>, +{ + match scalar { + Scalar::Bool(v) => ore(cipher, v, context).await, + Scalar::I32(v) => ore(cipher, v, context).await, + Scalar::I64(v) => ore(cipher, v, context).await, + Scalar::U32(v) => ore(cipher, v, context).await, + Scalar::U64(v) => ore(cipher, v, context).await, + Scalar::F32(v) => ore(cipher, v, context).await, + Scalar::F64(v) => ore(cipher, v, context).await, + Scalar::Text(t) => ore(cipher, t, context).await, + Scalar::Bytes(b) => ore(cipher, b, context).await, + } +} + +/// [`TermKind::Ope`] per scalar; see [`ore_of`] for why the text and bytes +/// arms pass the wrapper. +async fn ope_of<'c, K, D>( + cipher: &KeysetCipher<'_, K>, + scalar: Scalar, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + D: IntoPrfContext<'c>, +{ + match scalar { + Scalar::Bool(v) => ope(cipher, v, context).await, + Scalar::I32(v) => ope(cipher, v, context).await, + Scalar::I64(v) => ope(cipher, v, context).await, + Scalar::U32(v) => ope(cipher, v, context).await, + Scalar::U64(v) => ope(cipher, v, context).await, + Scalar::F32(v) => ope(cipher, v, context).await, + Scalar::F64(v) => ope(cipher, v, context).await, + Scalar::Text(t) => ope(cipher, t, context).await, + Scalar::Bytes(b) => ope(cipher, b, context).await, + } +} + +/// The `AsRef<[u8]>` on the output is what turns the typed CLLW ciphertext +/// into the frozen raw-bytes encoding. +async fn ore<'c, K, T, D>( + cipher: &KeysetCipher<'_, K>, + value: T, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + T: CllwOreEncrypt + Send + 'static, + T::Output: AsRef<[u8]> + Send + 'static, + D: IntoPrfContext<'c>, +{ + Ok(cipher + .ore_term(value, context) + .await + .map(|t| t.as_ref().to_vec())?) +} + +/// See [`ore`]. +async fn ope<'c, K, T, D>( + cipher: &KeysetCipher<'_, K>, + value: T, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + T: CllwOpeEncrypt + Send + 'static, + T::Output: AsRef<[u8]> + Send + 'static, + D: IntoPrfContext<'c>, +{ + Ok(cipher + .ope_term(value, context) + .await + .map(|t| t.as_ref().to_vec())?) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::{context, Output}; + use crate::{nonempty, StackCipher}; + use stack_kms::FakeDataKeySource; + + async fn cipher() -> StackCipher<FakeDataKeySource> { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher") + } + + fn s(value: &str) -> FfiValue { + FfiValue::String(value.into()) + } + + fn bytes(value: &[u8]) -> FfiValue { + FfiValue::Bytes(Protected::new(value.to_vec())) + } + + /// Every scalar variant, from the leaf it lifts out of. + fn every_scalar() -> Vec<(&'static str, FfiValue)> { + vec![ + ("a bool", FfiValue::Bool(true)), + ("an i32", FfiValue::Int32(-3)), + ("an i64", FfiValue::Int64(-4)), + ("a u32", FfiValue::UInt32(34)), + ("a u64", FfiValue::UInt64(35)), + ("an f32", FfiValue::Float32(1.5)), + ("an f64", FfiValue::Float64(2.5)), + ("text", s("alice")), + ("bytes", bytes(b"ab")), + ] + } + + mod given_a_scalar_the_scheme_defines_the_term_for { + use super::*; + + /// The contract the dispatch exists for: the bytes are the typed + /// path's, so a probe from any language finds a Rust-written term. + #[tokio::test] + async fn derives_the_bytes_the_typed_path_derives() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ctx = context(s("users/x")).expect("context"); + let dynamic = |value: &FfiValue, kind: TermKind| { + let scalar = Scalar::of(value, kind).expect("a scalar"); + term(&keyset, scalar, kind, ctx.clone()) + }; + let eq = |t: crate::sem::EqualityTerm| t.into_bytes().to_vec(); + + // Equality, per PRF-encodable variant. + let typed = keyset.equality_term(-3i32, nonempty!("users/x")).await; + assert_eq!( + dynamic(&FfiValue::Int32(-3), TermKind::Equality) + .await + .expect("eq"), + eq(typed.expect("typed")), + "i32 equality" + ); + let typed = keyset.equality_term(-4i64, nonempty!("users/x")).await; + assert_eq!( + dynamic(&FfiValue::Int64(-4), TermKind::Equality) + .await + .expect("eq"), + eq(typed.expect("typed")), + "i64 equality" + ); + let typed = keyset.equality_term(34u32, nonempty!("users/x")).await; + assert_eq!( + dynamic(&FfiValue::UInt32(34), TermKind::Equality) + .await + .expect("eq"), + eq(typed.expect("typed")), + "u32 equality" + ); + let typed = keyset.equality_term(35u64, nonempty!("users/x")).await; + assert_eq!( + dynamic(&FfiValue::UInt64(35), TermKind::Equality) + .await + .expect("eq"), + eq(typed.expect("typed")), + "u64 equality" + ); + let typed = keyset + .equality_term("alice".to_string(), nonempty!("users/x")) + .await; + assert_eq!( + dynamic(&s("alice"), TermKind::Equality).await.expect("eq"), + eq(typed.expect("typed")), + "text equality" + ); + let typed = keyset + .equality_term(Protected::new(b"ab".to_vec()), nonempty!("users/x")) + .await; + assert_eq!( + dynamic(&bytes(b"ab"), TermKind::Equality) + .await + .expect("eq"), + eq(typed.expect("typed")), + "bytes equality" + ); + + // Match, text only. + let typed = keyset + .match_terms::<DefaultMatch>("alice smith", nonempty!("users/x")) + .await + .expect("typed"); + assert_eq!( + dynamic(&s("alice smith"), TermKind::Match) + .await + .expect("match"), + typed.to_bytes(), + "text match" + ); + + // The ordering schemes take every scalar; the typed side is + // spelled once per variant because each is its own type. + macro_rules! ordered { + ($value:expr, $leaf:expr, $label:literal) => { + let typed = keyset.ore_term($value, nonempty!("users/x")).await; + assert_eq!( + dynamic(&$leaf, TermKind::Ore).await.expect("ore"), + typed.expect("typed").as_ref().to_vec(), + concat!($label, " ore") + ); + let typed = keyset.ope_term($value, nonempty!("users/x")).await; + assert_eq!( + dynamic(&$leaf, TermKind::Ope).await.expect("ope"), + typed.expect("typed").as_ref().to_vec(), + concat!($label, " ope") + ); + }; + } + ordered!(true, FfiValue::Bool(true), "bool"); + ordered!(-3i32, FfiValue::Int32(-3), "i32"); + ordered!(-4i64, FfiValue::Int64(-4), "i64"); + ordered!(34u32, FfiValue::UInt32(34), "u32"); + ordered!(35u64, FfiValue::UInt64(35), "u64"); + ordered!(1.5f32, FfiValue::Float32(1.5), "f32"); + ordered!(2.5f64, FfiValue::Float64(2.5), "f64"); + ordered!("alice".to_string(), s("alice"), "text"); + ordered!(b"ab".to_vec(), bytes(b"ab"), "bytes"); + } + + #[test] + fn supports_is_true() { + for (label, leaf) in every_scalar() { + let scalar = Scalar::of(&leaf, TermKind::Ore).expect("a scalar"); + assert!(TermKind::Ore.supports(&scalar), "{label} takes an ore term"); + assert!(TermKind::Ope.supports(&scalar), "{label} takes an ope term"); + } + for (label, leaf) in every_scalar() { + let scalar = Scalar::of(&leaf, TermKind::Equality).expect("a scalar"); + let prf_encodable = !matches!( + leaf, + FfiValue::Bool(_) | FfiValue::Float32(_) | FfiValue::Float64(_) + ); + assert_eq!( + TermKind::Equality.supports(&scalar), + prf_encodable, + "{label} takes an equality term exactly when it has a PRF encoding" + ); + assert_eq!( + TermKind::Match.supports(&scalar), + matches!(leaf, FfiValue::String(_)), + "{label} takes a match term exactly when it is text" + ); + } + } + } + + mod given_a_pair_the_scheme_refuses { + use super::*; + + /// The arms of `term` are unreachable for a pair `supports` refuses, + /// and they say so with the same error the table would have let a + /// binding raise at its boundary. + #[tokio::test] + async fn term_is_error_term_naming_the_kind() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ctx = context(s("users/x")).expect("context"); + let refused = [ + ("a bool", FfiValue::Bool(true), TermKind::Equality), + ("an f32", FfiValue::Float32(1.5), TermKind::Equality), + ("an f64", FfiValue::Float64(2.5), TermKind::Equality), + ("a bool", FfiValue::Bool(true), TermKind::Match), + ("a u32", FfiValue::UInt32(34), TermKind::Match), + ("bytes", bytes(b"ab"), TermKind::Match), + ]; + for (label, leaf, kind) in refused { + let scalar = Scalar::of(&leaf, kind).expect("a scalar"); + assert!( + !kind.supports(&scalar), + "{label} must not take a {kind} term" + ); + let result = term(&keyset, scalar, kind, ctx.clone()).await; + assert!( + matches!(result, Err(Error::Term { kind: k }) if k == kind), + "{label} asked for a {kind} term must be refused as that kind: {result:?}" + ); + } + } + } + + mod given_a_value_that_is_not_a_scalar { + use super::*; + + #[test] + fn lifting_is_error_term_naming_the_kind() { + let not_scalars = [ + ("null", FfiValue::Null), + ("undefined", FfiValue::Undefined), + ("an array", FfiValue::Array(vec![FfiValue::UInt32(1)])), + ( + "an object", + FfiValue::Object(vec![("k".to_string(), FfiValue::UInt32(1))]), + ), + ( + "a passthrough", + FfiValue::Passthrough(Box::new(FfiValue::UInt32(1))), + ), + ]; + for (label, value) in not_scalars { + for kind in [ + TermKind::Equality, + TermKind::Match, + TermKind::Ore, + TermKind::Ope, + ] { + let result = Scalar::of(&value, kind); + assert!( + matches!(result, Err(Error::Term { kind: k }) if k == kind), + "{label} has no {kind} term: {result:?}" + ); + } + } + } + } + + mod given_a_term_kind { + use super::*; + + #[test] + fn its_key_is_how_a_plan_spells_it() { + for kind in [ + TermKind::Equality, + TermKind::Match, + TermKind::Ore, + TermKind::Ope, + ] { + assert_eq!( + Output::parse(kind.key()), + Some(Output::Term(kind)), + "a plan spelling {kind} by its key names that term" + ); + assert_eq!( + kind.to_string(), + kind.key(), + "the display form is the key, for error messages" + ); + } + } + } + + mod given_a_scalar_holding_plaintext { + use super::*; + + #[test] + fn debug_prints_none_of_it() { + let rendered = format!( + "{:?}", + Scalar::of(&s("hunter2"), TermKind::Equality).expect("a scalar") + ); + assert!( + !rendered.contains("hunter2"), + "a scalar's Debug must not print its plaintext: {rendered}" + ); + assert!( + rendered.contains("Text"), + "a scalar's Debug names the variant, which is not secret: {rendered}" + ); + } + } +} diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs new file mode 100644 index 000000000..78638ddc5 --- /dev/null +++ b/packages/stack-encrypt/src/keyset.rs @@ -0,0 +1,1439 @@ +//! Keysets: the one thing a client holds more than one of. +//! +//! A [`StackCipher`] is scoped to a client — one ZeroKMS client, one client +//! key — and a client may use any number of keysets: one per tenant is the +//! common shape. A [`KeysetCipher`] is the cipher bound to one of them, and +//! it is what every operation that *mints* something binds to: sealing +//! values, sealing records, deriving index terms. Decrypting is not +//! keyset-scoped (a sealed leaf carries the id of the keyset it was sealed +//! under), so it lives on [`StackCipher`] as well, with the [`KeysetCipher`] +//! form adding a constraint rather than a capability — see +//! [`KeysetCipher::decrypt`]. +//! +//! Keysets load lazily. [`StackCipher::keyset`] resolves an id or a name +//! through a bounded cache and loads the keyset from ZeroKMS on a miss — +//! one round trip, paid once per keyset per process (or again after +//! eviction). That is the one async point: everything on the returned +//! handle keeps its shape. The default keyset — the one named on the +//! builder, else the client's — is loaded eagerly by +//! [`init`](crate::StackCipherBuilder::init), so a misconfigured client +//! fails at startup, and never evicts. +//! +//! # Ids are identity; names are looked up +//! +//! A keyset's id is its identity: a sealed leaf carries it, and an id +//! selection never needs re-checking. A name is a lookup ZeroKMS answers, +//! and ZeroKMS lets a keyset be renamed, so a name the cipher resolved +//! earlier can point at a different keyset later. The cache therefore +//! treats a name-to-id binding as fresh for a bounded time +//! ([`DEFAULT_NAME_TTL`], `StackCipherBuilder::keyset_name_ttl`) and +//! re-asks ZeroKMS after that — the way a resolver treats a DNS record. +//! Within the window a rename is invisible; a `Duration::ZERO` window makes +//! every name selection a round trip. The default keyset's builder-time +//! name is bound the same way: after the window, selecting it by name asks +//! ZeroKMS again. +//! +//! A keyset has one name at a time in ZeroKMS, so the cache keeps one name +//! per keyset: resolving a keyset under a new name means its old name was +//! renamed away, and that binding goes. And because resolutions run outside +//! the lock, their answers can land in any order; a binding follows the +//! *later lookup*, whichever answer arrives first, so an answer from before +//! a rename cannot overwrite one from after it — neither under the same +//! name, nor by taking back the name the keyset has since left, nor by +//! arriving after eviction has forgotten the answer it would have lost to, +//! nor after ZeroKMS has answered a later lookup that the name is bound to +//! nothing. + +use std::collections::HashMap; +use std::fmt; +use std::num::NonZeroUsize; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use stack_kms::IdentifiedBy; +use uuid::Uuid; +use vitaminc_hmac::HmacSha256Prf; + +use crate::StackCipher; + +/// How long a name-to-id binding is trusted before a selection by that +/// name asks ZeroKMS again. Five minutes bounds how long a rename can go +/// unnoticed by a running process; `StackCipherBuilder::keyset_name_ttl` +/// changes it. +pub const DEFAULT_NAME_TTL: Duration = Duration::from_secs(5 * 60); + +/// What the cipher holds per loaded keyset: its resolved id, the name it was +/// loaded under if any, and the PRF keyed by its index key. +pub(crate) struct KeysetState { + pub(crate) id: Uuid, + pub(crate) name: Option<String>, + pub(crate) prf: HmacSha256Prf, +} + +/// A loaded keyset in the cache, with the name it is currently bound under +/// if any — kept across replacement so the binding is dropped when the id +/// is evicted, however the entry was last loaded — and the lookup whose +/// answer last spoke for it. +/// +/// The default keyset is an `Entry` too, held apart from the bound rather +/// than shaped differently: its state never changes and it never evicts, but +/// its name binding ages, moves and reorders like any other, and every +/// accessor on [`KeysetCache`] reaches it through the same two lines +/// ([`entry`](KeysetCache::entry) / [`entry_mut`](KeysetCache::entry_mut)). +struct Entry { + state: Arc<KeysetState>, + last_used: u64, + name: Option<String>, + resolution: Resolution, +} + +/// A name-to-id binding: when ZeroKMS last confirmed it, and which lookup +/// asked. +struct Alias { + id: Uuid, + resolved_at: Instant, + resolution: Resolution, +} + +impl Alias { + /// Whether the binding is still trusted at `now`. Strictly within the + /// window: a zero window is never fresh, whatever the clock's + /// resolution — even read at the very instant it was bound, which is + /// what a coarse clock reports for a binding made moments ago. + fn is_fresh_at(&self, now: Instant, ttl: Duration) -> bool { + now.saturating_duration_since(self.resolved_at) < ttl + } +} + +/// A lookup's place in the order of lookups that went to ZeroKMS. The +/// caller carries it from [`get`](KeysetCache::get) to +/// [`insert`](KeysetCache::insert), where an answer is applied only if it is +/// later than the one that already spoke for that keyset, and its name only +/// if it is later than the one that produced that name's binding — answers +/// land in any order, and a later question has the later answer. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct Resolution(u64); + +impl Resolution { + /// Whether this lookup came after `other`: the one comparison every + /// ordering rule in [`KeysetCache`] is written in. Strict — each lookup + /// takes its own ticket, so two answers never share one, and "later" + /// never includes the answer itself. + fn is_later_than(self, other: Resolution) -> bool { + self > other + } +} + +/// What a lookup found. +pub(crate) enum Lookup { + /// A loaded keyset, and (for a name) a binding within its window. + Hit(Arc<KeysetState>), + /// A name binding past its window (the keyset it named may still be + /// loaded, but whether the name still means it is ZeroKMS's to say): the + /// caller re-resolves the name with ZeroKMS and [`insert`]s the result + /// with this ticket, which refreshes the binding — or moves it, if the + /// name did. + /// + /// [`insert`]: KeysetCache::insert + Stale(Resolution), + /// Nothing loaded for this id, or no binding for this name: the caller + /// loads it and [`insert`]s the result with this ticket. + /// + /// [`insert`]: KeysetCache::insert + Miss(Resolution), +} + +/// The bounded, least-recently-used cache of loaded keysets behind +/// [`StackCipher::keyset`]. +/// +/// Entries are keyed by id, with a name index beside them for the names +/// each id has been resolved under. Eviction drops the least recently +/// *used* entry, where a use is any lookup hit, together with every name +/// bound to it; nothing stored depends on the cache (a sealed leaf carries +/// its keyset id, and terms carry nothing), so eviction is invisible except +/// for the round trip the next lookup pays. The default keyset is held +/// apart and never evicts, though its name binding ages like any other. +/// +/// Hits are `O(1)`; an insert into a full cache scans for the oldest entry, +/// `O(n)` in the bound, which is the rare case by construction. The name +/// index is bounded by the entries it serves: one binding per cached id at +/// most, plus the default's, and a binding goes when its id does or when +/// the keyset is resolved under another name. What an evicted entry — or a +/// name ZeroKMS answered is bound to nothing — leaves behind is one +/// watermark, not a record per name: see [`watermark`](Self::watermark). +pub(crate) struct KeysetCache { + capacity: NonZeroUsize, + name_ttl: Duration, + /// Monotonic use counter; an entry's tick is the last time it was hit. + tick: u64, + /// Monotonic lookup counter; see [`Resolution`]. + resolutions: u64, + /// The default keyset's entry, held apart from the bound: it never + /// evicts, and [`insert`](Self::insert) never replaces its state. + default: Entry, + /// The latest lookup whose answer the cache holds nothing of to order + /// an older answer against: one eviction has forgotten, or one ZeroKMS + /// answered with "no keyset has this name". + /// + /// A keyset carries the order of the answers that spoke for it, and a + /// binding the order of the lookup that made it; evicting the keyset + /// drops both, and an answer older than what went would then find + /// nothing left to say it is the older one. So every eviction leaves the + /// entry's place here — its own bindings never sat later in the order + /// than it does, since the insert that binds a name is the insert that + /// stamps the entry — and no binding is made from an answer older than + /// this. A negative answer is the same case from the start: it is an + /// answer about a name that the cache holds no binding for, so it too + /// leaves its place here ([`forget`](Self::forget)), and an earlier + /// positive answer still in flight cannot bind the name after it. The + /// name is the only thing an answer too old to order can get wrong: an + /// id's key material is the same whichever lookup asked, so it still + /// caches. + /// + /// It is one watermark for all names rather than one per forgotten name + /// — a cache whose whole contract is a bound must not grow a record per + /// name it has evicted or been told is unbound — so it also refuses some + /// bindings an older lookup could have made safely. That costs a round + /// trip on the next selection by such a name, in the eviction regime + /// that is already paying them. + watermark: Resolution, + by_id: HashMap<Uuid, Entry>, + by_name: HashMap<String, Alias>, +} + +impl KeysetCache { + /// The bound a [`StackCipher`] uses unless the builder says otherwise: + /// a thousand-tenant process pays ZeroKMS once per tenant per cold + /// start and then not again. + pub(crate) const DEFAULT_CAPACITY: NonZeroUsize = NonZeroUsize::MIN.saturating_add(1023); + + /// A cache holding `default` apart from the bound; its builder-time + /// name, if any, is bound now. + pub(crate) fn new( + capacity: NonZeroUsize, + name_ttl: Duration, + default: Arc<KeysetState>, + ) -> Self { + let mut by_name = HashMap::new(); + if let Some(name) = &default.name { + let _ = by_name.insert( + name.clone(), + Alias { + id: default.id, + resolved_at: Instant::now(), + resolution: Resolution(0), + }, + ); + } + Self { + capacity, + name_ttl, + tick: 0, + resolutions: 0, + watermark: Resolution(0), + default: Entry { + last_used: 0, + name: default.name.clone(), + resolution: Resolution(0), + state: default, + }, + by_id: HashMap::new(), + by_name, + } + } + + /// The ticket for a lookup that is about to go to ZeroKMS. + fn resolution(&mut self) -> Resolution { + self.resolutions += 1; + Resolution(self.resolutions) + } + + /// The keyset held apart from the bound. + fn default_id(&self) -> Uuid { + self.default.state.id + } + + /// The entry for `id`, whether it is the default's or one of the bounded + /// ones. This and [`entry_mut`](Self::entry_mut) are the only two places + /// that know the default is held apart, so every rule below — ordering, + /// binding, forgetting a name — is written once and applies to it too. + fn entry(&self, id: Uuid) -> Option<&Entry> { + if id == self.default_id() { + Some(&self.default) + } else { + self.by_id.get(&id) + } + } + + /// [`entry`](Self::entry), mutably. + fn entry_mut(&mut self, id: Uuid) -> Option<&mut Entry> { + if id == self.default_id() { + Some(&mut self.default) + } else { + self.by_id.get_mut(&id) + } + } + + /// Mark `id` most recently used and hand back its state, if the cache + /// holds it at all. + fn touch(&mut self, id: Uuid) -> Option<Arc<KeysetState>> { + let tick = self.tick + 1; + let entry = self.entry_mut(id)?; + entry.last_used = tick; + let state = Arc::clone(&entry.state); + self.tick = tick; + Some(state) + } + + /// Look a keyset up by id or name, marking it most recently used. + pub(crate) fn get(&mut self, by: &IdentifiedBy) -> Lookup { + let (id, fresh) = match by { + IdentifiedBy::Uuid(id) => (*id, true), + IdentifiedBy::Name(name) => match self.by_name.get::<str>(name) { + Some(alias) => (alias.id, alias.is_fresh_at(Instant::now(), self.name_ttl)), + None => return Lookup::Miss(self.resolution()), + }, + }; + let Some(state) = self.touch(id) else { + return Lookup::Miss(self.resolution()); + }; + if fresh { + Lookup::Hit(state) + } else { + Lookup::Stale(self.resolution()) + } + } + + /// Record a keyset ZeroKMS just resolved for the lookup `resolution`, + /// evicting the least recently used entry first if the cache is full + /// and the id is new. Resolving the default keyset again never replaces + /// its state. + /// + /// The name it was resolved under (if any) is bound to its id as of now + /// — refreshing a binding that had aged, or moving one whose keyset was + /// renamed — unless a later lookup has already bound that name, in + /// which case this answer is the older one and the binding stands. A + /// keyset has one name, so binding it under a new name drops the old + /// one; and a name that moved to this keyset is dropped from the keyset + /// it used to name. No binding outlives the id it names. + /// + /// An answer older than the one this keyset already holds is dropped + /// whole. It has nothing newer to say about the keyset, and applying it + /// would undo what a later lookup applied — restoring, under a full + /// window, a name the keyset has since been renamed away from. + /// + /// Returns what the lookup should be answered with, which is not always + /// what ZeroKMS said: when a later lookup has already spoken — for the + /// name this one asked under first, else for this keyset — the caller + /// gets that later answer, the same one every selection after it gets. + /// The name comes first because it is what the caller asked: an answer + /// older than what its keyset holds *and* than what its name is bound + /// to is answered by the name, since the keyset's later answer may have + /// come under another name. The answer that lost is not handed out even + /// once, and that is decided before this insert evicts anything: the + /// entry it evicts can be the very winner. An answer nothing later + /// contradicts is returned as it is, whether or not its name bound (an + /// answer older than the watermark has no binding left to lose to, and + /// is still the latest thing the cache knows about its name). + pub(crate) fn insert( + &mut self, + state: Arc<KeysetState>, + resolution: Resolution, + ) -> Arc<KeysetState> { + // A name lookup is answered with whatever the name means now: when + // a later lookup has already bound it — to this keyset or another — + // the caller gets that keyset (held, since no binding outlives its + // id), and `bind` below refuses this answer as the older one. Taken + // first: before the keyset's own order is consulted, since the name + // is what was asked, and before eviction, which can take that very + // binding with the entry it evicts and leave this answer looking + // uncontradicted. + let later = state + .name + .as_deref() + .and_then(|name| self.by_name.get(name)) + .filter(|alias| alias.resolution.is_later_than(resolution)) + .and_then(|alias| self.entry(alias.id)) + .map(|entry| Arc::clone(&entry.state)); + if let Some(entry) = self.entry(state.id) { + if entry.resolution.is_later_than(resolution) { + return later.unwrap_or_else(|| Arc::clone(&entry.state)); + } + } + // Evict before binding: the entry that goes may be the one whose + // answer this one is older than, and its place in the order must be + // on the watermark before `bind` consults it — or an answer from + // before a rename binds a name it should have lost to the entry its + // own insert evicts. + if state.id != self.default_id() + && !self.by_id.contains_key(&state.id) + && self.by_id.len() >= self.capacity.get() + { + self.evict_oldest(); + } + // `bind` gives the entry the name it binds, when the cache already + // holds one; a first insert carries it over below instead. + let bound = match &state.name { + Some(name) => self.bind(name, state.id, resolution), + None => false, + }; + let answer = later.unwrap_or_else(|| Arc::clone(&state)); + if state.id == self.default_id() { + self.default.resolution = resolution; + return answer; + } + self.tick += 1; + match self.by_id.get_mut(&state.id) { + Some(entry) => { + entry.state = state; + entry.last_used = self.tick; + entry.resolution = resolution; + } + None => { + let name = bound.then(|| state.name.clone()).flatten(); + let _ = self.by_id.insert( + state.id, + Entry { + state, + last_used: self.tick, + name, + resolution, + }, + ); + } + } + answer + } + + /// Bind `name` to `id` for the lookup `resolution`; false if a later + /// lookup already bound it, or if this answer is older than a place in + /// the order the cache has since let go of ([`watermark`]). Also + /// unbinds the name this id was bound under before, and unbinds this + /// name from the id it named before. + /// + /// [`watermark`]: Self::watermark + fn bind(&mut self, name: &str, id: Uuid, resolution: Resolution) -> bool { + if self.watermark.is_later_than(resolution) { + return false; + } + if let Some(alias) = self.by_name.get(name) { + if alias.resolution.is_later_than(resolution) { + return false; + } + if alias.id != id { + self.forget_name_of(alias.id, name); + } + } + if let Some(previous) = self.current_name_of(id) { + if previous != name { + let _ = self.by_name.remove(&previous); + } + } + let _ = self.by_name.insert( + name.to_owned(), + Alias { + id, + resolved_at: Instant::now(), + resolution, + }, + ); + // The keyset claims the name it is now bound under, so eviction can + // take the binding with it. An id the cache does not hold yet is + // about to be inserted by `insert`, which carries the name over. + if let Some(entry) = self.entry_mut(id) { + entry.name = Some(name.to_owned()); + } + true + } + + /// The name `id` is currently bound under, if any. + fn current_name_of(&self, id: Uuid) -> Option<String> { + self.entry(id).and_then(|entry| entry.name.clone()) + } + + /// ZeroKMS answered the lookup `resolution` for `name` with "no keyset + /// has this name". That is an answer about the name, and it orders + /// like one: a binding an earlier lookup made goes (a later lookup's + /// stands — the name may have been given out again since), and the + /// [`watermark`](Self::watermark) rises to this lookup, so an earlier + /// positive answer still in flight cannot bind the name after ZeroKMS + /// has said it is bound to nothing. Only ZeroKMS's own answer counts: + /// a lookup that failed to get one (transport, auth) says nothing about + /// the name and must not come here. + pub(crate) fn forget(&mut self, name: &str, resolution: Resolution) { + self.watermark = self.watermark.max(resolution); + let Some(alias) = self.by_name.get(name) else { + return; + }; + if alias.resolution.is_later_than(resolution) { + return; + } + let id = alias.id; + let _ = self.by_name.remove(name); + self.forget_name_of(id, name); + } + + /// `name` moved away from `id`: the id no longer claims it. + fn forget_name_of(&mut self, id: Uuid, name: &str) { + if let Some(entry) = self.entry_mut(id) { + if entry.name.as_deref() == Some(name) { + entry.name = None; + } + } + } + + fn evict_oldest(&mut self) { + let Some(oldest) = self + .by_id + .iter() + .min_by_key(|(_, entry)| entry.last_used) + .map(|(id, _)| *id) + else { + return; + }; + if let Some(entry) = self.by_id.remove(&oldest) { + // The entry's place in the order outlives it as a watermark: + // once it is gone there is nothing left to order an older answer + // for this keyset against. It is taken whether or not the entry + // still owns a name — a keyset whose name has already moved to + // another keyset is precisely the one an older answer would + // rebind, and the binding it would have lost to is no longer + // here to say so. + self.watermark = self.watermark.max(entry.resolution); + if let Some(name) = entry.name { + // A name that has since moved to another id keeps its + // binding: only this id's binding goes with it. + if self + .by_name + .get(&name) + .is_some_and(|alias| alias.id == oldest) + { + let _ = self.by_name.remove(&name); + } + } + } + } + + /// Insert as a fresh, in-order resolution — what every test that is not + /// about ordering wants. + #[cfg(test)] + pub(crate) fn load(&mut self, state: Arc<KeysetState>) { + let resolution = self.resolution(); + let _ = self.insert(state, resolution); + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.by_id.len() + } + + #[cfg(test)] + pub(crate) fn names(&self) -> usize { + self.by_name.len() + } +} + +/// A [`StackCipher`] bound to one keyset: what sealing and term derivation +/// bind to, and what a decrypt that must stay within one keyset binds to. +/// +/// Obtained from [`StackCipher::keyset`] (any keyset, loaded on first use) +/// or [`StackCipher::default_keyset`]. Cheap to clone and to hold: a +/// reference to the cipher plus a shared handle on the keyset's loaded +/// state, so a request handler can take one per tenant and hand it around. +/// +/// The type a caller holds states the guarantee it gets. A `KeysetCipher` +/// for tenant A mints every data key under A's keyset, derives every term +/// under A's index key, and refuses — before any ZeroKMS call — to open a +/// leaf sealed under any other keyset ([`Error::ForeignKeyset`]). The +/// [`StackCipher`] it came from opens leaves from any keyset the client +/// is authorised for. +/// +/// [`Error::ForeignKeyset`]: crate::Error::ForeignKeyset +pub struct KeysetCipher<'k, K> { + cipher: &'k StackCipher<K>, + state: Arc<KeysetState>, +} + +impl<K> Clone for KeysetCipher<'_, K> { + fn clone(&self) -> Self { + Self { + cipher: self.cipher, + state: Arc::clone(&self.state), + } + } +} + +/// Opaque: the keyset's identity (which is in every sealed leaf already, and +/// in ZeroKMS's own logs) and nothing else. The index-key PRF this handle +/// carries is key material and never appears, and neither does the cipher — +/// whose own [`Debug`](fmt::Debug) is opaque for the same reason. +impl<K> fmt::Debug for KeysetCipher<'_, K> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("KeysetCipher") + .field("keyset_id", &self.state.id) + .field("keyset_name", &self.state.name) + .field("kms", &std::any::type_name::<K>()) + .finish_non_exhaustive() + } +} + +impl<'k, K> KeysetCipher<'k, K> { + pub(crate) fn new(cipher: &'k StackCipher<K>, state: Arc<KeysetState>) -> Self { + Self { cipher, state } + } + + /// The client-scoped cipher this keyset belongs to. + pub fn cipher(&self) -> &'k StackCipher<K> { + self.cipher + } + + /// The keyset every data key this handle mints is generated under, and + /// whose index key keys [`prf`](Self::prf). Resolved: a keyset selected + /// by name reports its id here. + pub fn keyset_id(&self) -> Uuid { + self.state.id + } + + /// The name this keyset's loaded state was last resolved under, if any. + /// That is the name of the lookup that loaded (or last refreshed) it, + /// not necessarily of the selection that produced this handle: a + /// selection by id returns state another selection may have loaded by + /// name, and the default keyset knows its name only when the builder + /// named it. A label from the time of loading, not an identity: see the + /// [module docs](self#ids-are-identity-names-are-looked-up). + pub fn keyset_name(&self) -> Option<&str> { + self.state.name.as_deref() + } + + /// The PRF index terms are derived from, keyed by this keyset's index + /// key. + /// + /// Public so that other crates can implement their own term types + /// against this cipher (see [`crate::sem`]). + pub fn prf(&self) -> &HmacSha256Prf { + &self.state.prf + } + + /// The underlying data-key source. + pub fn kms(&self) -> &'k K { + self.cipher.kms() + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use vitaminc_prf::PrfKeyInit; + use vitaminc_protected::Protected; + + fn state(id: u128, name: Option<&str>) -> Arc<KeysetState> { + Arc::new(KeysetState { + id: Uuid::from_u128(id), + name: name.map(str::to_owned), + prf: HmacSha256Prf::new(Protected::new([id as u8; 32])), + }) + } + + fn name(name: &str) -> IdentifiedBy { + IdentifiedBy::Name(name.to_string().into()) + } + + fn id(id: u128) -> IdentifiedBy { + IdentifiedBy::Uuid(Uuid::from_u128(id)) + } + + /// A cache whose default is keyset 0 (unnamed) and whose name window + /// never closes. + fn cache(capacity: usize) -> KeysetCache { + KeysetCache::new( + NonZeroUsize::new(capacity).unwrap(), + Duration::MAX, + state(0, None), + ) + } + + fn hit(lookup: Lookup) -> Option<Uuid> { + match lookup { + Lookup::Hit(state) => Some(state.id), + Lookup::Stale(_) | Lookup::Miss(_) => None, + } + } + + fn ticket(lookup: Lookup) -> Resolution { + match lookup { + Lookup::Stale(r) | Lookup::Miss(r) => r, + Lookup::Hit(_) => panic!("expected a lookup that goes to ZeroKMS"), + } + } + + #[test] + fn a_keyset_is_found_by_id_and_by_the_name_it_loaded_under() { + let mut cache = cache(4); + cache.load(state(1, Some("customers"))); + + assert_eq!( + hit(cache.get(&id(1))), + Some(Uuid::from_u128(1)), + "a loaded keyset is found by its id" + ); + assert_eq!( + hit(cache.get(&name("customers"))), + Some(Uuid::from_u128(1)), + "and by the name it loaded under" + ); + assert!( + matches!(cache.get(&name("staff")), Lookup::Miss(_)), + "a name nothing was loaded under is a miss" + ); + assert!( + matches!(cache.get(&id(2)), Lookup::Miss(_)), + "an id nothing was loaded under is a miss" + ); + } + + #[test] + fn a_keyset_loaded_by_id_is_not_found_by_name() { + let mut cache = cache(4); + cache.load(state(1, None)); + + assert!( + matches!(cache.get(&id(1)), Lookup::Hit(_)), + "the id it loaded under finds it" + ); + assert!( + matches!(cache.get(&name("customers")), Lookup::Miss(_)), + "a load by id binds no name, so no name finds it" + ); + } + + /// The documented bound on how long a rename can go unnoticed. + #[test] + fn a_name_binding_is_trusted_for_five_minutes_by_default() { + assert_eq!( + DEFAULT_NAME_TTL, + Duration::from_secs(300), + "name bindings should be trusted for five minutes by default" + ); + } + + /// Every ordering rule in the cache is written in `is_later_than`, so + /// its strictness is pinned here once: a lookup is not later than + /// itself, nor than one after it. + #[test] + fn later_is_strictly_later() { + assert!( + Resolution(2).is_later_than(Resolution(1)), + "a later resolution should compare later" + ); + assert!( + !Resolution(1).is_later_than(Resolution(1)), + "a resolution should not be later than itself" + ); + assert!( + !Resolution(1).is_later_than(Resolution(2)), + "an earlier resolution should not compare later" + ); + } + + /// A binding is fresh strictly inside its window. The zero-window case + /// is read at the very instant of binding — what a coarse clock (a + /// millisecond `performance.now()`, say) reports for a binding made + /// moments ago — and is still stale. + #[test] + fn a_binding_is_fresh_strictly_within_its_window() { + let bound = Instant::now(); + let alias = Alias { + id: Uuid::from_u128(1), + resolved_at: bound, + resolution: Resolution(1), + }; + let ttl = Duration::from_secs(60); + + assert!( + alias.is_fresh_at(bound, ttl), + "a binding should be fresh at its start" + ); + assert!( + alias.is_fresh_at(bound + ttl - Duration::from_nanos(1), ttl), + "a binding should be fresh just before expiry" + ); + assert!( + !alias.is_fresh_at(bound + ttl, ttl), + "the window's end is outside it" + ); + assert!( + !alias.is_fresh_at(bound, Duration::ZERO), + "a zero window is never fresh" + ); + } + + /// No two uses are ever tied for least recently used — a load is newer + /// than every use before it, and so is a hit — so eviction takes the + /// same keyset in every cache, whatever order its hasher's seed happens + /// to iterate the entries in. + #[test] + fn eviction_follows_use_order_not_hash_order() { + for _ in 0..64 { + let (mut loads, mut hits) = (cache(2), cache(2)); + + loads.load(state(1, None)); + loads.load(state(2, None)); + loads.load(state(3, None)); + assert!( + matches!(loads.get(&id(1)), Lookup::Miss(_)), + "the first loaded is the least recently used" + ); + assert!( + matches!(loads.get(&id(2)), Lookup::Hit(_)), + "the second loaded keyset should remain cached" + ); + + hits.load(state(1, None)); + hits.load(state(2, None)); + assert!( + matches!(hits.get(&id(1)), Lookup::Hit(_)), + "the first keyset should be cached before its hit" + ); + hits.load(state(3, None)); + assert!( + matches!(hits.get(&id(2)), Lookup::Miss(_)), + "a hit makes 1 newer than the 2 loaded after it" + ); + assert!( + matches!(hits.get(&id(1)), Lookup::Hit(_)), + "the recently used keyset should survive eviction" + ); + } + } + + /// A name that moved to another keyset is no longer the old keyset's to + /// give up: renaming the old keyset afterwards must not unbind the name + /// from the keyset that now answers to it. + #[test] + fn renaming_the_keyset_a_name_left_does_not_take_the_name() { + let mut cache = cache(4); + cache.load(state(1, Some("acme"))); + cache.load(state(2, Some("acme"))); + cache.load(state(1, Some("legacy"))); + + assert_eq!( + hit(cache.get(&name("acme"))), + Some(Uuid::from_u128(2)), + "the name stays with the keyset it moved to" + ); + assert_eq!( + hit(cache.get(&name("legacy"))), + Some(Uuid::from_u128(1)), + "and the old keyset answers to its new name" + ); + assert_eq!( + cache.names(), + 2, + "both names should remain bound after the rename" + ); + } + + /// The default here is keyset 7, not the nil id the other tests' caches + /// default to: a cache that mistook the nil id for its default would + /// pass every test whose default *is* nil. + #[test] + fn the_default_is_found_by_id_and_its_builder_name_but_never_stored() { + let mut cache = KeysetCache::new( + NonZeroUsize::new(1).unwrap(), + Duration::MAX, + state(7, Some("primary")), + ); + assert_eq!( + hit(cache.get(&id(7))), + Some(Uuid::from_u128(7)), + "the default is found by its id" + ); + assert_eq!( + hit(cache.get(&name("primary"))), + Some(Uuid::from_u128(7)), + "and by the name the builder gave it" + ); + + // Filling the one slot evicts nothing of the default's. + cache.load(state(1, None)); + cache.load(state(2, None)); + assert_eq!(cache.len(), 1, "the bounded part holds its one slot"); + assert_eq!( + hit(cache.get(&id(7))), + Some(Uuid::from_u128(7)), + "the default survives an eviction that filled the bound" + ); + assert_eq!( + hit(cache.get(&name("primary"))), + Some(Uuid::from_u128(7)), + "and so does its name binding" + ); + + // Re-resolving the default by name refreshes its binding, and does + // not put a second copy of it in the bounded part. + cache.load(state(7, Some("primary"))); + assert_eq!( + cache.len(), + 1, + "re-resolving the default must not store a second copy of it" + ); + + // The default renamed: its old name no longer selects it. + cache.load(state(7, Some("main"))); + assert_eq!( + hit(cache.get(&name("main"))), + Some(Uuid::from_u128(7)), + "the default's new name selects it" + ); + assert!( + matches!(cache.get(&name("primary")), Lookup::Miss(_)), + "the name it was renamed away from no longer selects it" + ); + assert_eq!(cache.names(), 1, "a keyset holds one name at a time"); + } + + #[test] + fn the_least_recently_used_keyset_is_evicted_with_its_names() { + let mut cache = cache(2); + cache.load(state(1, Some("one"))); + cache.load(state(2, Some("two"))); + // Touch 1 so 2 is the oldest. + assert!( + matches!(cache.get(&id(1)), Lookup::Hit(_)), + "touching 1 makes 2 the least recently used" + ); + + cache.load(state(3, Some("three"))); + + assert_eq!(cache.len(), 2, "the cache stays at its bound"); + assert!(matches!(cache.get(&id(2)), Lookup::Miss(_)), "2 was oldest"); + assert!( + matches!(cache.get(&name("two")), Lookup::Miss(_)), + "the evicted keyset's name goes with it" + ); + assert!( + matches!(cache.get(&id(1)), Lookup::Hit(_)), + "the touched keyset stayed" + ); + assert!( + matches!(cache.get(&id(3)), Lookup::Hit(_)), + "and the newly loaded one is held" + ); + } + + /// The order two cold lookups on the same keyset can land in: by name + /// first, then by id. The id load carries no name, but must not shed + /// the binding the name load made — or eviction would later leave that + /// binding pointing at an id the cache no longer holds. + #[test] + fn a_reload_by_id_keeps_the_names_a_keyset_was_bound_under() { + let mut cache = cache(1); + cache.load(state(1, Some("one"))); + cache.load(state(1, None)); + assert_eq!(cache.len(), 1, "the same id replaces, never adds"); + assert_eq!( + hit(cache.get(&name("one"))), + Some(Uuid::from_u128(1)), + "a reload by id must not shed the binding the name load made" + ); + + // Evicting 1 takes "one" with it, whichever load was last. + cache.load(state(2, None)); + assert!( + matches!(cache.get(&id(1)), Lookup::Miss(_)), + "1 was evicted by 2" + ); + assert!( + matches!(cache.get(&name("one")), Lookup::Miss(_)), + "no binding outlives the id it names" + ); + assert_eq!(cache.names(), 0, "the name index emptied with the entry"); + + // And reloading 1 by id does not resurrect the binding. + cache.load(state(1, None)); + assert!( + matches!(cache.get(&name("one")), Lookup::Miss(_)), + "a reload by id binds no name" + ); + } + + /// Bindings never outnumber the keysets they name: churning names + /// through a one-slot cache leaves one binding, not a thousand. + #[test] + fn the_name_index_is_bounded_by_the_cache() { + let mut cache = cache(1); + for i in 1..=1000u128 { + cache.load(state(i, Some(&format!("tenant-{i}")))); + cache.load(state(i, None)); + } + assert_eq!(cache.len(), 1, "the cache holds its bound, not 1000"); + assert_eq!( + cache.names(), + 1, + "the name index is bounded by the entries it serves" + ); + assert_eq!( + hit(cache.get(&name("tenant-1000"))), + Some(Uuid::from_u128(1000)), + "the surviving binding is the last one made" + ); + } + + /// A keyset has one name at a time: resolved under a new one, its old + /// name was renamed away and no longer selects it. The index therefore + /// never holds more bindings than keysets, however often one is renamed. + #[test] + fn a_keysets_newer_name_replaces_its_older_one() { + let mut cache = cache(1); + cache.load(state(1, Some("one"))); + cache.load(state(1, Some("uno"))); + assert_eq!( + hit(cache.get(&name("uno"))), + Some(Uuid::from_u128(1)), + "the newer name selects the keyset" + ); + assert!( + matches!(cache.get(&name("one")), Lookup::Miss(_)), + "the older name was renamed away and no longer selects it" + ); + assert_eq!(cache.names(), 1, "one name per keyset"); + + for i in 0..1000 { + cache.load(state(1, Some(&format!("name-{i}")))); + } + assert_eq!( + cache.names(), + 1, + "a thousand renames of one keyset leave one binding" + ); + + cache.load(state(2, None)); + assert_eq!( + cache.names(), + 0, + "evicting the keyset takes its one binding with it" + ); + } + + /// Resolutions run outside the lock and their answers land in any + /// order. The binding follows the later lookup: an answer from before a + /// rename that arrives after the answer from after it must not move the + /// name back. Key material is cached by id either way. + #[test] + fn a_binding_follows_the_later_lookup_whichever_answer_lands_first() { + let mut cache = cache(4); + let earlier = ticket(cache.get(&name("acme"))); + let later = ticket(cache.get(&name("acme"))); + + cache.insert(state(2, Some("acme")), later); + cache.insert(state(1, Some("acme")), earlier); + + assert_eq!( + hit(cache.get(&name("acme"))), + Some(Uuid::from_u128(2)), + "the binding follows the later lookup, not the later arrival" + ); + assert!( + matches!(cache.get(&id(1)), Lookup::Hit(_)), + "the older answer's key material still caches, by id" + ); + assert_eq!(cache.names(), 1, "one binding for the one name"); + + // In order, the later answer moves it as usual. + let next = ticket(cache.get(&name("other"))); + cache.insert(state(1, Some("acme")), next); + assert_eq!( + hit(cache.get(&name("acme"))), + Some(Uuid::from_u128(1)), + "a genuinely later answer moves the binding" + ); + assert_eq!(cache.names(), 1, "2 no longer claims the name"); + } + + /// The same race with the two lookups asking *different* names, which + /// is the shape a rename actually takes: a selection by the old name + /// starts, the keyset is renamed, a selection by the new name starts + /// and answers first. The older answer must not take the old name back + /// — it would route that name, which ZeroKMS may have given to another + /// keyset, here for a whole window. + #[test] + fn an_older_answer_does_not_restore_a_name_the_keyset_has_left() { + let mut cache = cache(4); + cache.load(state(1, Some("acme"))); + + cache.name_ttl = Duration::ZERO; + let earlier = ticket(cache.get(&name("acme"))); + let later = ticket(cache.get(&name("acme-corp"))); + cache.name_ttl = Duration::MAX; + + cache.insert(state(1, Some("acme-corp")), later); + cache.insert(state(1, Some("acme")), earlier); + + assert_eq!( + hit(cache.get(&name("acme-corp"))), + Some(Uuid::from_u128(1)), + "the later answer's name stands" + ); + assert!( + matches!(cache.get(&name("acme")), Lookup::Miss(_)), + "the name the keyset was renamed away from is not bound again" + ); + assert_eq!(cache.names(), 1, "and it was not bound alongside"); + } + + /// And the default keyset, held apart from the bound, orders its + /// answers the same way. + #[test] + fn the_defaults_binding_also_follows_the_later_lookup() { + let mut cache = KeysetCache::new( + NonZeroUsize::new(4).unwrap(), + Duration::ZERO, + state(0, Some("primary")), + ); + + let earlier = ticket(cache.get(&name("primary"))); + let later = ticket(cache.get(&name("main"))); + cache.name_ttl = Duration::MAX; + + cache.insert(state(0, Some("main")), later); + cache.insert(state(0, Some("primary")), earlier); + + assert_eq!( + hit(cache.get(&name("main"))), + Some(Uuid::from_u128(0)), + "the default's binding follows the later lookup too" + ); + assert!( + matches!(cache.get(&name("primary")), Lookup::Miss(_)), + "the older answer does not restore the builder-time name" + ); + assert_eq!(cache.names(), 1, "one name for the default as well"); + } + + /// A rename: the name now resolves to another id. The binding moves, + /// and evicting the id it used to name does not take it away. + #[test] + fn a_name_that_moved_to_another_keyset_follows_it() { + let mut cache = cache(2); + cache.load(state(1, Some("acme"))); + cache.load(state(2, Some("acme"))); + assert_eq!( + hit(cache.get(&name("acme"))), + Some(Uuid::from_u128(2)), + "the name moved to the keyset that now answers to it" + ); + + // Evict 1 (the oldest): "acme" belongs to 2 now and stays. + assert!(matches!(cache.get(&id(2)), Lookup::Hit(_)), "touch 2"); + cache.load(state(3, None)); + assert!( + matches!(cache.get(&id(1)), Lookup::Miss(_)), + "1 was the least recently used and went" + ); + assert_eq!( + hit(cache.get(&name("acme"))), + Some(Uuid::from_u128(2)), + "evicting the keyset a name has left must not take the binding" + ); + } + + /// Eviction must not lose the order either: the keyset the later answer + /// named can be evicted — taking the binding, and the entry that ordered + /// it — while the earlier answer is still in flight. Landing in a cache + /// that holds neither id and no binding for the name, it must still not + /// bind the name it asked under. + #[test] + fn an_older_answer_does_not_bind_a_name_eviction_has_forgotten() { + let mut cache = cache(1); + let earlier = ticket(cache.get(&name("acme"))); + let later = ticket(cache.get(&name("acme"))); + + cache.insert(state(2, Some("acme")), later); + // 2 is evicted, and "acme" goes with it. + cache.load(state(3, None)); + assert_eq!(cache.names(), 0, "the binding went with the entry"); + + cache.insert(state(1, Some("acme")), earlier); + assert!( + matches!(cache.get(&name("acme")), Lookup::Miss(_)), + "the name the later lookup moved away is not taken back" + ); + assert_eq!(cache.names(), 0, "and nothing else was bound either"); + + // A lookup later than the evicted binding still binds: the watermark + // does not close the name index for good. + let next = ticket(cache.get(&name("acme"))); + cache.insert(state(1, Some("acme")), next); + assert_eq!( + hit(cache.get(&name("acme"))), + Some(Uuid::from_u128(1)), + "a lookup later than the watermark binds as usual" + ); + } + + /// An entry carries its place in the order whether or not it still owns + /// a name, and eviction must leave that place behind either way. A + /// keyset whose name has already moved to another keyset is exactly the + /// one an old answer would rebind: here three lookups resolve `old` to + /// keyset 1, then `new` to keyset 1, then `new` to keyset 2, and the + /// first answer — from before either rename — lands last, into a cache + /// that evicted keyset 1 after taking `new` off it. + #[test] + fn an_evicted_keyset_leaves_its_place_in_the_order_with_or_without_a_name() { + let mut cache = cache(1); + let oldest = ticket(cache.get(&name("old"))); + let middle = ticket(cache.get(&name("new"))); + let newest = ticket(cache.get(&name("new"))); + + // `new` meant 1, then 2: binding the later answer takes the name off + // 1, and caching 2 evicts 1 with no name of its own to leave behind. + cache.insert(state(1, Some("new")), middle); + cache.insert(state(2, Some("new")), newest); + assert_eq!( + hit(cache.get(&name("new"))), + Some(Uuid::from_u128(2)), + "`new` means keyset 2 after the second rename" + ); + + cache.insert(state(1, Some("old")), oldest); + assert!( + matches!(cache.get(&name("old")), Lookup::Miss(_)), + "a name from before two renames is not bound by the answer that lands last" + ); + assert_eq!(cache.names(), 0, "and no other binding was made"); + } + + /// The entry an old answer's own insert evicts can be the very one its + /// binding should lose to, so eviction must happen before the binding + /// is tried: with room for one, `old` resolves to keyset 1, then to + /// keyset 2, then keyset 2 is renamed `new`, and the first answer lands + /// last — into a cache that holds keyset 2 under `new` and has no + /// binding for `old` at all. Caching keyset 1 evicts keyset 2; the + /// watermark that eviction leaves is what refuses the stale `old`. + #[test] + fn an_older_answer_does_not_bind_a_name_past_the_entry_its_own_insert_evicts() { + let mut cache = cache(1); + let oldest = ticket(cache.get(&name("old"))); + let middle = ticket(cache.get(&name("old"))); + let _ = cache.insert(state(2, Some("old")), middle); + let newest = ticket(cache.get(&name("new"))); + let _ = cache.insert(state(2, Some("new")), newest); + assert!( + matches!(cache.get(&name("old")), Lookup::Miss(_)), + "renaming keyset 2 to `new` took `old` off it" + ); + + let answer = cache.insert(state(1, Some("old")), oldest); + assert_eq!( + answer.id, + Uuid::from_u128(1), + "nothing later is known about `old`, so the answer stands for this lookup" + ); + assert_eq!( + hit(cache.get(&id(1))), + Some(Uuid::from_u128(1)), + "and keyset 1 is cached by id, evicting keyset 2" + ); + assert!( + matches!(cache.get(&name("old")), Lookup::Miss(_)), + "but an answer older than the entry its insert evicted binds no name" + ); + assert_eq!(cache.names(), 0, "and no other binding was made"); + } + + /// The caller of a lookup whose answer lost to a later one is answered + /// with the later one — the keyset every selection after it gets — not + /// with the answer that lost, which would mint under a keyset the name + /// has since left. + #[test] + fn a_lookup_whose_answer_lost_is_answered_with_the_one_that_won() { + let mut cache = cache(4); + let earlier = ticket(cache.get(&name("acme"))); + let later = ticket(cache.get(&name("acme"))); + + // `acme` moved from keyset 1 to keyset 2 between the two lookups, + // and the later answer lands first. + let _ = cache.insert(state(2, Some("acme")), later); + let answer = cache.insert(state(1, Some("acme")), earlier); + assert_eq!( + answer.id, + Uuid::from_u128(2), + "the earlier lookup is answered with what `acme` means now" + ); + assert_eq!( + hit(cache.get(&id(1))), + Some(Uuid::from_u128(1)), + "keyset 1 is still cached by id: its key material is right whichever lookup asked" + ); + + // The same keyset resolved under a newer name: the older answer is + // dropped whole, and its caller gets the state the keyset holds. + let earlier = ticket(cache.get(&name("acme-corp"))); + let later = ticket(cache.get(&name("acme-corp"))); + let _ = cache.insert(state(3, Some("acme-corp")), later); + let answer = cache.insert(state(3, Some("acme")), earlier); + assert_eq!( + answer.name.as_deref(), + Some("acme-corp"), + "an answer older than the keyset's own is replaced by the keyset's" + ); + } + + /// The later answer reaches the earlier caller even when caching the + /// earlier one evicts it: what the name means is decided before the + /// eviction, or the eviction the losing insert triggers would take the + /// winner — alias and all — and leave the loser as the only answer. + #[test] + fn a_lookup_whose_answer_lost_is_answered_with_the_winner_its_own_insert_evicts() { + let mut cache = cache(1); + let earlier = ticket(cache.get(&name("acme"))); + let later = ticket(cache.get(&name("acme"))); + let _ = cache.insert(state(2, Some("acme")), later); + + let answer = cache.insert(state(1, Some("acme")), earlier); + assert_eq!( + answer.id, + Uuid::from_u128(2), + "the earlier lookup is answered with what `acme` means now, though caching its answer evicted keyset 2" + ); + assert_eq!( + hit(cache.get(&id(1))), + Some(Uuid::from_u128(1)), + "keyset 1 is cached by id all the same" + ); + assert!( + matches!(cache.get(&name("acme")), Lookup::Miss(_)), + "and the loser bound no name: it is older than the watermark keyset 2 left" + ); + } + + /// Newer information can exist for both the name asked and the keyset + /// answered: `old` resolves to keyset 1, then `old` moves to keyset 2, + /// then keyset 1 is resolved under `new`, and the first answer lands + /// last. It is older than what keyset 1 holds, so it is dropped whole — + /// but its caller asked for `old`, and `old` means keyset 2 now. + #[test] + fn a_dropped_answer_is_answered_by_its_name_before_its_keyset() { + let mut cache = cache(4); + let oldest = ticket(cache.get(&name("old"))); + let middle = ticket(cache.get(&name("old"))); + let newest = ticket(cache.get(&name("new"))); + let _ = cache.insert(state(2, Some("old")), middle); + let _ = cache.insert(state(1, Some("new")), newest); + + let answer = cache.insert(state(1, Some("old")), oldest); + assert_eq!( + answer.id, + Uuid::from_u128(2), + "the caller asked for `old`, which means keyset 2 now — not keyset 1, whose own later answer came under `new`" + ); + assert_eq!( + hit(cache.get(&name("old"))), + Some(Uuid::from_u128(2)), + "`old` still means keyset 2" + ); + assert_eq!( + hit(cache.get(&name("new"))), + Some(Uuid::from_u128(1)), + "and `new` still means keyset 1" + ); + assert_eq!(cache.names(), 2, "no other binding was made"); + } + + /// ZeroKMS answering "no keyset has this name" is an answer about the + /// name: the binding an earlier lookup made goes, an earlier positive + /// answer still in flight cannot bind the name after it, a later lookup + /// binds as usual, and a negative answer older than the binding that + /// stands leaves it standing. A zero window, so every name lookup is a + /// lookup with a ticket and a bound name reads as `Stale`. + #[test] + fn a_negative_answer_unbinds_a_name_and_refuses_earlier_answers_for_it() { + let mut cache = KeysetCache::new( + NonZeroUsize::new(4).unwrap(), + Duration::ZERO, + state(0, None), + ); + cache.load(state(1, Some("acme"))); + let earlier = ticket(cache.get(&name("acme"))); + let negative = ticket(cache.get(&name("acme"))); + + cache.forget("acme", negative); + assert!( + matches!(cache.get(&name("acme")), Lookup::Miss(_)), + "the binding the earlier load made is gone" + ); + assert_eq!( + hit(cache.get(&id(1))), + Some(Uuid::from_u128(1)), + "the keyset itself stays cached: only the name was answered" + ); + + let answer = cache.insert(state(1, Some("acme")), earlier); + assert_eq!( + answer.id, + Uuid::from_u128(1), + "nothing later positive is known about `acme`, so the answer stands for its own caller" + ); + assert!( + matches!(cache.get(&name("acme")), Lookup::Miss(_)), + "but it binds no name: ZeroKMS has since said `acme` is bound to nothing" + ); + + let later = ticket(cache.get(&name("acme"))); + let _ = cache.insert(state(2, Some("acme")), later); + assert!( + matches!(cache.get(&name("acme")), Lookup::Stale(_)), + "a lookup later than the negative answer binds as usual" + ); + + let stale_negative = ticket(cache.get(&name("acme"))); + let fresher = ticket(cache.get(&name("acme"))); + let _ = cache.insert(state(3, Some("acme")), fresher); + cache.forget("acme", stale_negative); + assert!( + matches!(cache.get(&name("acme")), Lookup::Stale(_)), + "a negative answer older than the binding that stands says nothing about it" + ); + } + + /// Past the window a name lookup is stale — the keyset is still there, + /// the binding is not trusted — and a re-resolution refreshes it. + #[test] + fn a_name_binding_ages_out_and_is_refreshed_by_reinsertion() { + let mut cache = KeysetCache::new( + NonZeroUsize::new(4).unwrap(), + Duration::ZERO, + state(0, Some("primary")), + ); + cache.load(state(1, Some("one"))); + + assert!( + matches!(cache.get(&name("one")), Lookup::Stale(_)), + "past its window a name binding is not trusted" + ); + assert!( + matches!(cache.get(&name("primary")), Lookup::Stale(_)), + "the default's builder-time name ages the same way" + ); + assert!( + matches!(cache.get(&id(1)), Lookup::Hit(_)), + "an id never ages" + ); + + // A zero window is stale again immediately after a refresh — with + // no time elapsed at all, on the coarsest clock — which is the point + // of a zero window; a wide one is fresh. + cache.load(state(1, Some("one"))); + assert!( + matches!(cache.get(&name("one")), Lookup::Stale(_)), + "a zero window is stale again the instant it is refreshed" + ); + cache.name_ttl = Duration::MAX; + assert!( + matches!(cache.get(&name("one")), Lookup::Hit(_)), + "a wide window is fresh" + ); + } +} diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs new file mode 100644 index 000000000..30fb58bfa --- /dev/null +++ b/packages/stack-encrypt/src/lib.rs @@ -0,0 +1,316 @@ +#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +// Security lints +#![deny(unsafe_code)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +// Prevent mem::forget from bypassing ZeroizeOnDrop +#![warn(clippy::mem_forget)] +// Prevent accidental data leaks via output +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Relax in tests +#![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))] +//! Encrypt Rust values under per-value ZeroKMS data keys. +//! +//! A [`StackCipher`] is scoped to one ZeroKMS client, and a [`KeysetCipher`] — +//! the cipher bound to one of that client's keysets, from +//! [`default_keyset`](StackCipher::default_keyset) or +//! [`keyset`](StackCipher::keyset) — encrypts any value that implements +//! [`Encrypt`] (`String`, `Vec<T>`, `HashMap<K, V>`, `Option<T>`, +//! `Protected<T>`, your own types, and any nesting of them). Either cipher +//! decrypts back into any [`Decrypt`] type. Every scalar inside the value is +//! sealed under its **own** ZeroKMS data key, so each value access is an +//! individually auditable key retrieval — there is no long-lived key in your +//! process. +//! +//! # Quick start +//! +// `StackCipher::new()` builds a ZeroKMS client from the environment, so it +// only exists with `http`. Without it the entry point is +// `StackCipher::builder().kms(..)` over an explicit data-key source — the +// shape the WASI/wazero guest builds against; see "Testing without ZeroKMS" +// below for the same call over the in-memory stub. +#![cfg_attr( + feature = "http", + doc = r#"```no_run +# async fn example() -> Result<(), Box<dyn std::error::Error>> { +use stack_encrypt::StackCipher; + +// Credentials: `npx stash auth login` on a developer machine, or +// CS_CLIENT_ID / CS_CLIENT_KEY + CS_CLIENT_ACCESS_KEY / CS_WORKSPACE_CRN in CI. +let cipher = StackCipher::new().await?; +let keyset = cipher.default_keyset(); + +// A `&str` encrypts as it is; decryption is owned, so it comes back a +// `String` — nothing borrows from a ciphertext. +let ciphertext = keyset.encrypt("secret message", ()).await?; +let plaintext: String = cipher.decrypt(ciphertext, ()).await?; +assert_eq!(plaintext, "secret message"); +# Ok(()) +# } +```"# +)] +#![cfg_attr( + not(feature = "http"), + doc = "Without the `http` feature a cipher is built over an explicit\ + data-key source — `StackCipher::builder().kms(..).init()` — rather than from\ + the environment. Enable `http` for `StackCipher::new()`, which discovers\ + ZeroKMS credentials itself." +)] +//! +//! Encrypting binds to a keyset (every data key is minted under one); decrypting +//! does not (every sealed leaf carries the id of the keyset it was sealed +//! under), so it goes through the client-scoped `cipher` — or through the +//! `keyset`, which then refuses leaves from any other keyset. +//! [`default_keyset`](StackCipher::default_keyset) is the client's own — +//! the keyset a ZeroKMS administrator set for it — and is always that one. +//! A client may use many others, one per tenant say; [`StackCipher::keyset`] +//! selects any of them by id or name, loading it on first use. The [`keyset`](crate::keyset) +//! module docs lay out the model. +//! +//! The second argument is the *associated data* (AAD): anything that implements +//! [`IntoAad`] — `()`, `&[u8]`, `&str`, a tuple, or a derived [`Context`]. It is +//! authenticated, not encrypted, and must be supplied identically on decrypt. +//! Use it to bind a ciphertext to its context (a table name, a tenant, a record +//! id) so it cannot be replayed elsewhere: +//! +//! ```no_run +//! # async fn example<K: stack_kms::DataKeySource>(cipher: stack_encrypt::StackCipher<K>) -> Result<(), stack_encrypt::Error> { +//! # let keyset = cipher.default_keyset(); +//! let ct = keyset.encrypt("4111 1111 1111 1111", "users/42/card").await?; +//! let card: String = cipher.decrypt(ct, "users/42/card").await?; // ok +//! # Ok(()) +//! # } +//! ``` +//! +// Credentials only exist on the `http` path: without it there is no client +// to authenticate, only the `DataKeySource` the caller supplies. +#![cfg_attr( + feature = "http", + doc = r#"# Credentials + +A cipher needs two credentials, resolved independently of each other: + +- a **client key** — an id and key material, which data keys are derived + against; and +- an **auth strategy** — whatever obtains a token ZeroKMS will accept. + +Each is looked for in the environment first, then in the current workspace of +the CLI's profile directory (`~/.cipherstash`), which `npx stash auth login` +writes. A logged-in developer machine has both there, so +`StackCipher::new()` usually just works with nothing else set. + +Where there is no profile — CI, a container, wasm — the environment carries +them. `CS_CLIENT_ID` + `CS_CLIENT_KEY` are the client key. +`CS_CLIENT_ACCESS_KEY` is the auth strategy `AutoStrategy` detects, and it +needs a workspace CRN (`CS_WORKSPACE_CRN`) alongside it: the profile is what +supplies that otherwise, and its region drives service discovery while its +workspace id verifies every token issued. + +An access key is not the only way to authenticate, and often not the one a +service wants. A `stack_auth::OidcFederationStrategy` federates a +third-party OIDC JWT (Clerk, Supabase, Auth0) into a CipherStash token, so +the deployment holds no long-lived CipherStash credential of its own. What +`AutoStrategy` detects is only the two above — access key, then profile — so +any other strategy is named explicitly, and that is what +[`kms`](StackCipherBuilder::kms) is for: build the +[`StackKms`](stack_kms::StackKms) over the strategy you want and hand it to +the builder. + +```no_run +# async fn example() -> Result<(), Box<dyn std::error::Error>> { +use stack_auth::{AuthError, AuthStrategyFn, SecretToken, ServiceToken}; +use stack_encrypt::StackCipher; +use stack_kms::{EnvKeyProvider, StackKmsBuilder}; + +// Any `AuthStrategy` goes in this slot — `AccessKeyStrategy`, +// `OidcFederationStrategy`, `DeviceSessionStrategy`, or, as here, +// `AuthStrategyFn` over a closure of your own. The closure is called +// whenever ZeroKMS needs a fresh token, so refresh belongs inside it. Note +// what holds the token: `SecretToken` is zeroized on drop and prints as +// `***`, so a long-lived credential neither lingers in freed memory nor +// lands in a log line. +let token = SecretToken::new(std::env::var("MY_SERVICE_TOKEN")?); +let strategy = AuthStrategyFn::new(move || { + let token = token.clone(); + async move { Ok::<_, AuthError>(ServiceToken::new(token)) } +}); + +// The client key is the other half, and has its own provider: `EnvKeyProvider` +// reads CS_CLIENT_ID / CS_CLIENT_KEY, or supply a `KeyProvider` of your own. +let kms = StackKmsBuilder::new(strategy) + .with_key_provider(EnvKeyProvider) + .build() + .await?; + +let cipher = StackCipher::builder().kms(kms).init().await?; +# Ok(()) +# } +``` + +The built-in strategies are constructed from a workspace CRN +(`stack_auth::Crn`) rather than read from the environment — +`OidcFederationStrategy::new(crn, provider)` — and otherwise reach the +builder through the same `kms` seam. + +`examples/zerokms_auth.rs` runs this end to end against a live ZeroKMS, +alongside the default path and the errors each half fails with. The +transport knobs — timeouts, batch size, concurrency, an alternate ZeroKMS +endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are +[`keyset_cache_size`](StackCipherBuilder::keyset_cache_size) and +[`keyset_name_ttl`](StackCipherBuilder::keyset_name_ttl). +"# +)] +//! +//! # Testing without ZeroKMS +//! +//! `stack_kms::FakeDataKeySource` is an in-memory stub that needs no +//! credentials or network: it hands out a fresh random data key per request and +//! remembers it in memory, so a `generate` followed by the matching `retrieve` +//! round-trips within one process (the key material itself differs run to run, +//! and nothing survives the process). It models none of ZeroKMS's +//! authorization behaviour (context, identity claims, decryption policies) — +//! those are the service's, and tests of them belong against a real ZeroKMS. +//! It lives behind stack-kms's `test-support` feature, so add +//! `stack-kms = { version = "..", features = ["test-support"] }` to your +//! `[dev-dependencies]`: +//! +//! ``` +//! use stack_encrypt::StackCipher; +//! use stack_kms::FakeDataKeySource; +//! +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! let cipher = StackCipher::builder() +//! .kms(FakeDataKeySource::new()) +//! .init() +//! .await?; +//! let keyset = cipher.default_keyset(); +//! let ct = keyset.encrypt(vec!["a".to_string(), "b".to_string()], ()).await?; +//! let pt: Vec<String> = cipher.decrypt(ct, ()).await?; +//! assert_eq!(pt, vec!["a", "b"]); +//! # Ok::<(), stack_encrypt::Error>(()) +//! # }).unwrap(); +//! ``` +//! +//! # Storing ciphertext +//! +//! [`encrypt`](KeysetCipher::encrypt) returns a [`StackCipherText`]: a tree whose +//! shape mirrors the value (a scalar is a single leaf, a `Vec` a sequence of +//! leaves, a map a set of named leaves) and whose leaves are [`SealedValue`]s. +//! A `SealedValue` is the persistable unit: its canonical, frozen byte +//! encoding is [`to_bytes`](SealedValue::to_bytes) / +//! [`from_bytes`](SealedValue::from_bytes) — the format a database column +//! holds and every language binding reads. Each leaf carries the id of the +//! keyset it was sealed under, which is what lets a column be opened with no +//! keyset named. For callers that manage their own +//! storage format it also implements `serde` `Serialize`/`Deserialize` and +//! offers [`into_parts`](SealedValue::into_parts) / +//! [`from_parts`](SealedValue::from_parts). Map keys are stored in the clear +//! (and authenticated); nothing else about a value is visible without its +//! data keys. Index terms have their own frozen encodings — see +//! [`sem`](crate::sem#byte-encodings). +//! +//! # What is authenticated +//! +//! Besides your AAD, the *shape* of a value is authenticated: an element cannot +//! be spliced out of a sequence and passed off as a scalar, a map value cannot +//! be moved under a different key, and "absent" / "empty" are themselves +//! sealed markers rather than inferable from structure. A tampered or +//! re-homed ciphertext fails with [`Error::Aead`]; a failed or denied key +//! retrieval surfaces as [`Error::Kms`]. A ciphertext opened under the +//! *wrong context* is refused by ZeroKMS first: every data key is bound to +//! its context's [`Descriptor`], so the retrieve is denied +//! ([`Error::Kms`], a forbidden request) before the AEAD runs — as +//! `examples/encrypted_record.rs` shows against a live ZeroKMS. Only a key +//! source that ignores descriptors (`FakeDataKeySource`, in tests) lets a +//! wrong context reach the AEAD, where it is [`Error::Aead`]. +//! +//! For one-row reads of a batch-encrypted collection, decrypt as +//! [`Element<T>`](Element) under the same AAD used for the whole collection: +//! the derivation that binds an element to its position is applied by the +//! type, not by the caller. That is the general rule here — every leaf's AAD +//! is derived from the context its key was minted under, and there is no +//! entry point that lets a caller supply one of its own. +//! +//! # Relationship to vitaminc +//! +//! A `KeysetCipher` is a vitaminc [`Cipher`]; everything a vitaminc cipher can +//! encrypt, it can encrypt, and the AEAD, AAD derivations and leaf wire format +//! are vitaminc's (`vitaminc_encrypt::Aes256Cipher`, AES-256-GCM under a random +//! per-leaf nonce vitaminc generates itself). The ZeroKMS `iv` a [`SealedValue`] +//! carries is *not* that nonce: it identifies the data key, and is sent back to +//! ZeroKMS with the key `tag` to re-derive it — under the same [`Descriptor`] +//! (the leaf's context, rendered) the key was generated with, which ZeroKMS +//! binds into the tag and logs. The types a caller needs from +//! vitaminc are re-exported here. The [`cipher`] module docs describe the +//! internals (batching, AAD derivation, wire format). + +/// This crate's version, for a binding to put in the `user-agent` of the +/// ZeroKMS requests it makes. +/// +/// A request is identified by the library that makes it, not by whichever +/// binding shim is carrying it: a product token of `stack-encrypt/0.1.0` +/// (the `product/version` spelling a `user-agent` is made of, with the +/// host in a comment after it — the Go guest sends +/// `stack-encrypt/0.1.0 (Go)`) means the same thing from the WASI guest +/// under Go as from a native cdylib under Python. The +/// native Rust client does not go through a binding and identifies itself +/// as `stack-kms` (see `stack_kms`'s user agent) — the crate that actually +/// makes its requests. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub mod cipher; +pub mod descriptor; +#[cfg(feature = "dynamic")] +pub mod dynamic; +pub mod keyset; +pub mod sem; +pub mod target; + +pub use cipher::{ + BoxedPassthrough, Error, FromEnv, LeafBytesError, PendingStackCipherText, SealedValue, + StackCipher, StackCipherBuilder, StackCipherText, StackDecipher, +}; +pub use descriptor::Descriptor; +pub use keyset::KeysetCipher; +pub use target::{ + CallerContext, CipherScope, DecryptField, DecryptFrom, DecryptInto, Decryptable, Decryption, + EncryptFrom, EncryptInto, Encryption, Pending, PendingFuture, Request, Responses, +}; + +// Re-export the vitaminc AEAD surface callers need to drive the cipher, so they +// don't have to depend on `vitaminc-aead` directly for the common path. A +// context type of your own implements `IntoContext` once; `IntoAad` and +// `IntoPrfContext` are vitaminc's blankets over it, so both derivations see +// the same bytes. +pub use vitaminc_aead::{ + Cipher, CipherText, Context, ContextPiece, ContextTag, Decipher, Decrypt, Element, Encrypt, + IntoAad, IntoContext, Unspecified, +}; +// The vitaminc 0.4 names, deprecated there; re-exported for one transition so +// a caller that spelled them keeps compiling with a warning. +#[allow(deprecated)] +pub use vitaminc_aead::{Aad, AadPiece}; + +// Likewise the PRF context surface, so a caller who needs the PRF view of a +// context has no direct `vitaminc-prf` dependency for it. +pub use vitaminc_prf::IntoPrfContext; +#[allow(deprecated)] +pub use vitaminc_prf::PrfContext; + +// And the proof every target-directed leaf asks for: a `NonEmpty<T>` is what +// `encrypt_into_with_context` / `decrypt_into` take, built with `nonempty!` +// (a literal, checked at compile time) or `NonEmpty::new` (a runtime value, +// checked once); `MaybeEmpty` is what a context type of your own implements +// to be wrapped. `#[derive(EncryptFrom)]` names these through this crate. +pub use vitaminc_protected::{nonempty, nonempty_bytes, EmptyError, MaybeEmpty, NonEmpty}; diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs new file mode 100644 index 000000000..03db5c663 --- /dev/null +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -0,0 +1,1158 @@ +//! Searchable Encrypted Metadata (SEM) term types. +//! +//! Index terms are stored alongside a +//! [`StackCipherText`](crate::StackCipherText) so encrypted values can be +//! queried without decryption. Each term type implements +//! [`EncryptFrom`](crate::EncryptFrom), so the usual entry point is +//! target-directed: +//! +//! ```text +//! let term: EqualityTerm = value.encrypt_into_with_context(&cipher, nonempty!("users/email")).await?; +//! ``` +//! +//! * [`EqualityTerm`] — a PRF of the whole value; exact-match queries. +//! * [`MatchTerm`] — the value is tokenized locally, each token is PRF'd, and +//! the outputs fold into Bloom-filter bit positions; full-text match +//! queries. Tokenizer/filter parameters are a *type-level* config +//! ([`MatchConfig`]) so write-time and query-time terms agree by +//! construction. +//! * [`OreTerm`] / [`OpeTerm`] — CLLW order-revealing / order-preserving +//! ciphertexts produced *inside the PRF visitor* from a per-context key +//! derived through the PRF; range queries. +//! +//! Every term type here is built on exactly one thing the cipher exposes +//! publicly — its PRF ([`KeysetCipher::prf`], keyed by the index key of the +//! keyset the handle is bound to). These core implementations own term +//! generation; downstream storage types can wrap the supported operations or +//! consume their native output with a visitor (see [`target`](crate::target)). +//! Terms bind to a keyset the way sealed values do: a term derived through +//! one tenant's [`KeysetCipher`] compares only against terms derived through +//! the same keyset. +//! +//! Alongside the target-directed path, the keyset cipher carries descriptor +//! methods ([`KeysetCipher::equality_term`] and friends) for call sites that +//! want a single term rather than a whole record — query builders, mostly. +//! Each returns a [`Pending`], as the record path does; they derive no data +//! keys, and under the local HMAC backend the pending carries no requests, +//! so a probe settles without a ZeroKMS call — though a backend that derives +//! terms at ZeroKMS settles it through the same pending. The +//! descriptor is the same [`NonEmpty`] context the target-directed path +//! takes, so the two agree byte for byte. +//! +//! # PRF backends, visitors, and the 2-party future +//! +//! The PRF backend produces **blocks**; a [`PrfVisitor`] shapes blocks into +//! the term (`EqualityVisitor`, `BloomVisitor`, `OreVisitor`, `OpeVisitor` — +//! all private). The shaping is pure and synchronous by construction: only block +//! production can involve I/O, so a visitor never knows which side of a +//! round-trip it runs on. All pure work — option validation, tokenization — +//! happens *before* the PRF is invoked. +//! +//! The backend today is the local +//! [`HmacSha256Prf`] — keyed by the +//! deterministic per-keyset [`IndexKey`](stack_kms::IndexKey) from +//! [`stack_kms::IndexKeySource`], loaded when the keyset is selected — so +//! every derivation completes with no I/O and an [`EncryptFrom`](crate::EncryptFrom) term +//! carries **no requests** in its [`Pending`]. That is the backend's +//! property, not the API's: the term is a `Pending` either way. The next +//! ZeroKMS release adds 2-party PRF generation; under +//! that backend a term's `encrypt_from` pushes a PRF *request* instead and +//! runs the **same visitor** over the blocks the server returns — the shaping +//! code does not change, and terms then share the one batched ZeroKMS call +//! with the record's data keys. This is also why ORE/OPE *keys* are derived +//! through the PRF (from the field context, never the plaintext): under a +//! 2-party backend, per-field key derivation becomes a visible, auditable +//! ZeroKMS event while plaintext stays local. +//! +//! # Determinism and domain separation +//! +//! Index terms are deterministic by design — the same value under the same +//! context always yields the same term, which is what makes them queryable +//! (and is the usual SEM leakage trade-off: equal values are visibly equal). +//! Every term kind derives under its own PAE-encoded domain, bound to the +//! caller's context, so the same value indexed as an equality term, a match +//! token, or an ORE key can never produce colliding PRF outputs. +//! +//! This is a fresh (v2) term format: PRF inputs are framed with vitaminc's PAE +//! context encoding, so terms are intentionally **not** byte-compatible with +//! `cipherstash-client`'s existing `IndexTerm` values. +//! +//! # Byte encodings +//! +//! Terms cross the wasm/FFI boundary into other languages, so each term kind +//! commits to one frozen **transport** encoding — the bytes a language +//! binding decodes: +//! +//! * [`EqualityTerm`] — the 32 PRF bytes as-is +//! ([`as_bytes`](EqualityTerm::as_bytes) / +//! [`to_bytes`](EqualityTerm::to_bytes) / +//! [`from_bytes`](EqualityTerm::from_bytes)). +//! * [`MatchTerm`] — the sorted, de-duplicated bit positions, each a +//! little-endian `u16` ([`to_bytes`](MatchTerm::to_bytes) / +//! [`from_bytes`](MatchTerm::from_bytes)). +//! * [`OreTerm`] / [`OpeTerm`] — the raw CLLW ciphertext bytes, unframed +//! ([`as_bytes`](OreTerm::as_bytes) / [`to_bytes`](OreTerm::to_bytes) / +//! [`from_bytes`](OreTerm::from_bytes)). +//! +//! Every kind decodes through `TryFrom<&[u8]>` as well, failing with a +//! [`TermBytesError`]; [`EqualityTerm`] additionally keeps an infallible +//! [`from_bytes`](EqualityTerm::from_bytes) over a `[u8; 32]`. `as_bytes` +//! exists only where the term *is* a contiguous buffer (equality, ORE, OPE); +//! a [`MatchTerm`] is canonically a position list, so it has none. +//! +//! For equality and ORE/OPE the transport bytes are also the stored form, +//! and they share the *shape* of the v1 / `cipherstash-client` encodings — a +//! 32-byte HMAC for equality, raw CLLW bytes for ORE/OPE, the same bytes EQL +//! hex-encodes into its `hm` / `oc` / `op` fields with its hex and JSON +//! framing sitting *above* them. A match term is the exception: what is +//! stored and queried is the position list +//! ([`positions`](MatchTerm::positions)), which maps to an integer-array +//! column (EQL sends `bf` as a JSON integer array) — no column holds the +//! `u16` byte string, which is stack-encrypt's own shape and exists so a +//! binding can carry the term across the boundary without inventing a +//! framing. +//! +//! The **values are not comparable**: as noted above the derivations differ, +//! and stack-encrypt has no EQL integration of its own. A term compares only +//! against terms produced by the same stack-encrypt keyset — never against a +//! row `cipherstash-client` or EQL v1 wrote. What the shared shape buys is a +//! decoder: a language binding reading these bytes needs no framing of its +//! own. There is deliberately no version byte or framing here: a term is an +//! opaque comparand and its derivation is already versioned by the PAE domain +//! labels above. The pins in `tests/term_bytes.rs` and +//! `tests/frozen_bytes.rs` hold both the derivations and the encodings in +//! place: a binding depends on the transport bytes, so they are frozen even +//! where no column holds them. + +mod tokenize; + +pub use tokenize::Tokenizer; + +use std::fmt; +use std::marker::PhantomData; + +// Re-exported because they appear in this module's public bounds +// ([`KeysetCipher::ore_term`], [`OreTerm`], ...): a caller writing a generic +// wrapper over the term APIs has to be able to name them without depending +// on `cllw-ore` directly. +pub use cllw_ore::{CllwOpeEncrypt, CllwOreEncrypt}; +use vitaminc_hmac::HmacSha256Prf; +use vitaminc_prf::{ + BlockVisitor, Context, IntoPrfContext, MapAccess, PrfError, PrfValue, PrfVisitor, + PrfVisitorError, SeqAccess, +}; +use vitaminc_protected::NonEmpty; +use zeroize::Zeroize; + +use stack_kms::MaybeSend; + +use crate::target::core::Term; +use crate::target::{DecryptField, Decryptable, Decryption}; +use crate::{Error, KeysetCipher, Pending}; + +// The `/v1` suffix versions the *derivation* (domain + input framing), not the +// crate. Any change to the bytes a term derives from must bump it: a changed +// derivation under an unchanged domain makes every existing term silently +// unfindable, with no error to notice. The byte-level pins in +// `tests/term_bytes.rs` are what force that bump to be deliberate. + +/// PAE domain for equality (exact-match) terms. +const EQUALITY_DOMAIN: &[u8] = b"stack-encrypt/sem/equality/v1"; +/// PAE domain for match (full-text) token terms. +const MATCH_DOMAIN: &[u8] = b"stack-encrypt/sem/match/v1"; +/// PAE domain for ORE key derivation. +const ORE_KEY_DOMAIN: &[u8] = b"stack-encrypt/sem/ore-key/v1"; +/// PAE domain for OPE key derivation (distinct from ORE: OPE ciphertexts are +/// encrypt-only, so the two schemes must never share a key). +const OPE_KEY_DOMAIN: &[u8] = b"stack-encrypt/sem/ope-key/v1"; + +/// Errors from SEM term generation. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum TermError { + /// The PRF backend failed (for a remote 2-party backend this includes + /// transport errors). + #[error("PRF failed: {0}")] + Prf(#[source] Box<dyn std::error::Error + Send + Sync + 'static>), + /// CLLW ORE/OPE encryption failed. + #[error("ORE/OPE encryption failed: {0}")] + Ore(#[from] cllw_ore::Error), + /// The supplied [`MatchOptions`] are invalid. + #[error("invalid match options: {0}")] + InvalidOptions(&'static str), + /// The text produced no tokens under the configured tokenizer — empty or + /// separator-only text, or (for n-grams, as in the v1 match indexer) text + /// shorter than the n-gram length. Rejected at generation time for both + /// the write and query paths: an empty term used as a query would + /// vacuously match every stored row, and a sub-gram-length probe could + /// never match anything (a silent false negative). + #[error( + "text produces no match tokens (empty, separator-only, or shorter than the n-gram length)" + )] + EmptyTermText, + /// Term bytes do not decode under the term kind's frozen encoding — see + /// [`TermBytesError`]. + #[error(transparent)] + Bytes(#[from] TermBytesError), +} + +/// A term's frozen byte encoding failed to decode (see the +/// [module docs](self#byte-encodings)). Purely structural — a term that +/// *decodes* has proven nothing about being a genuine term derived under any +/// particular keyset; bytes that fail here were never a valid encoding of +/// that term kind at all. +/// +/// Kept separate from the rest of [`TermError`] (which it converts into) so +/// decoding has an error a caller can compare: the generation variants carry +/// boxed and opaque sources that are not [`PartialEq`]. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum TermBytesError { + /// Equality-term bytes are not the 32 PRF bytes. + #[error("equality-term bytes must be exactly 32 bytes, got {0}")] + WrongEqualityTermLength(usize), + /// Match-term bytes are not a whole number of little-endian `u16` + /// positions. + #[error("match-term bytes must be little-endian u16 positions, got an odd length of {0}")] + OddMatchTermLength(usize), + /// A decoded position lies outside the Bloom filter the term's + /// [`MatchConfig`] fixes. Genuine positions are always masked into + /// `0..m`, so an out-of-range one means the bytes were not written by + /// this encoding — a wrong-endian decoder, most often, which would + /// otherwise decode cleanly and then silently never match. + #[error("match position {position} is outside the {filter_size}-bit filter")] + MatchPositionOutOfRange { + /// The offending position. + position: u16, + /// The filter size (`m`) the [`MatchConfig`] fixes. + filter_size: u32, + }, + /// The buffer's length is not one this CLLW ciphertext shape can have. + /// (The length is all there is to report: `cllw_ore::Error` is + /// deliberately contentless, so its message would say strictly less.) + #[error("{0} bytes do not fit this CLLW ciphertext shape")] + MalformedCllwCiphertext(usize), +} + +impl TermError { + fn from_prf<E>(err: PrfError<E>) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::Prf(Box::new(err)) + } +} + +// ============================================================================= +// Equality +// ============================================================================= + +/// An equality (exact-match) index term: one PRF block over the whole value. +/// +/// Terms are pseudorandom under the index key; they are stored server-side and +/// are not secret key material. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct EqualityTerm([u8; 32]); + +impl EqualityTerm { + /// Rebuild a term from stored bytes — the inverse of + /// [`into_bytes`](Self::into_bytes), for terms persisted server-side. + /// Infallible: the width is in the type. For a slice of unknown length + /// use `TryFrom<&[u8]>`. + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Owned copy of [`as_bytes`](Self::as_bytes) — the same `to_bytes` every + /// other term kind offers (see the [module docs](self#byte-encodings)). + pub fn to_bytes(&self) -> Vec<u8> { + self.0.to_vec() + } + + pub fn into_bytes(self) -> [u8; 32] { + self.0 + } +} + +/// Decode a slice of unknown length — the fallible counterpart of +/// [`EqualityTerm::from_bytes`], and the same `TryFrom<&[u8]>` every other +/// term kind offers. +impl TryFrom<&[u8]> for EqualityTerm { + type Error = TermBytesError; + + fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> { + <[u8; 32]>::try_from(bytes) + .map(Self) + .map_err(|_| TermBytesError::WrongEqualityTermLength(bytes.len())) + } +} + +impl From<EqualityTerm> for Vec<u8> { + fn from(term: EqualityTerm) -> Self { + term.0.to_vec() + } +} + +/// The frozen byte encoding — the 32 PRF bytes as-is (see the +/// [module docs](self#byte-encodings)). +impl AsRef<[u8]> for EqualityTerm { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +/// A [`PrfVisitor`] that wraps one PRF block as an [`EqualityTerm`]. +struct EqualityVisitor; + +impl<P: Send + 'static> PrfVisitor<[u8; 32], P> for EqualityVisitor { + type Value = EqualityTerm; + + fn visit_block(self, block: [u8; 32]) -> Result<Self::Value, PrfVisitorError> { + Ok(EqualityTerm(block)) + } +} + +/// Derive an equality term. Synchronous: the local HMAC backend does no I/O, +/// and the visitor does all the shaping (see the module docs — under a +/// deferred backend the same visitor runs after the round-trip instead). +/// `context` is the encoding of a [`NonEmpty`] — every caller holds one — +/// so there is nothing left to validate here. +fn equality<T>( + prf: &HmacSha256Prf, + value: T, + context: Context<'_>, +) -> Result<EqualityTerm, TermError> +where + T: PrfValue, +{ + let context = Context::pae(&[EQUALITY_DOMAIN, context.as_bytes()]); + value + .prf_visit_with_context(prf, context, EqualityVisitor) + .into_result() + .map_err(TermError::from_prf) +} + +/// An equality term of any [`PrfValue`] source. Under the local HMAC +/// backend, derived during the synchronous build — the returned [`Pending`] +/// carries no requests. +impl<'c, S, K, T> Term<S, K, NonEmpty<T>> for EqualityTerm +where + S: PrfValue + Clone, + T: IntoPrfContext<'c>, +{ + // Derived locally: no data key, no descriptor. + + fn encrypt_from<'a>( + source: &S, + cipher: &'a KeysetCipher<'_, K>, + context: NonEmpty<T>, + ) -> Pending<'a, Self, K> + where + Self: 'a, + { + let context = context.into_prf_context().into_owned(); + let term = equality(cipher.prf(), source.clone(), context).map_err(Error::from); + Pending::ready(cipher, term) + } +} + +// ============================================================================= +// Match +// ============================================================================= + +/// Options controlling match-term generation. The defaults mirror the existing +/// match indexer: 3-gram tokens, downcased, `k = 3` hash slices into an +/// `m = 256`-bit filter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MatchOptions { + /// How text splits into tokens. + pub tokenizer: Tokenizer, + /// Lower-case the text before tokenization (case-insensitive matching). + pub downcase: bool, + /// Number of bit positions derived per token, `3..=16` (the v1 match + /// indexer's bounds; the upper bound is also the PRF block size — each + /// position consumes 2 bytes of the 32-byte block). + pub k: usize, + /// Bloom filter size in bits. Must be a power of two in `[32, 65536]` + /// (the v1 match indexer's bounds). + pub m: u32, +} + +impl Default for MatchOptions { + fn default() -> Self { + Self { + tokenizer: Tokenizer::default(), + downcase: true, + k: 3, + m: 256, + } + } +} + +impl MatchOptions { + // Bounds mirror the v1 match indexer (`cipherstash-core`'s + // `bloom_filter`: K_MIN/K_MAX/M_MIN/M_MAX) so the same configuration + // validates identically across the two stacks. + fn validate(&self) -> Result<u16, TermError> { + if let Tokenizer::Ngram { length: 0 } = self.tokenizer { + return Err(TermError::InvalidOptions( + "n-gram length must be at least 1", + )); + } + if !(3..=16).contains(&self.k) { + return Err(TermError::InvalidOptions("k must be in 3..=16")); + } + if !self.m.is_power_of_two() || !(32..=65536).contains(&self.m) { + return Err(TermError::InvalidOptions( + "m must be a power of two in [32, 65536]", + )); + } + // For m = 65536 the mask is u16::MAX; positions always fit in u16. + Ok((self.m - 1) as u16) + } +} + +/// Type-level match configuration: the [`MatchOptions`] a [`MatchTerm<Self>`] +/// is generated with. Putting the configuration on the *type* means a record +/// field and the query probing it agree on tokenizer and filter parameters by +/// construction. Define your own by implementing this on a marker type. +pub trait MatchConfig: Send + Sync + 'static { + fn options() -> MatchOptions; +} + +/// The default [`MatchConfig`]: [`MatchOptions::default`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct DefaultMatch; + +impl MatchConfig for DefaultMatch { + fn options() -> MatchOptions { + MatchOptions::default() + } +} + +/// A match (full-text) index term: the set bit positions of a Bloom filter over +/// the PRF outputs of the value's tokens, generated under the [`MatchConfig`] +/// `O`. Positions are sorted and de-duplicated. +pub struct MatchTerm<O = DefaultMatch> { + positions: Vec<u16>, + _config: PhantomData<fn() -> O>, +} + +impl<O> MatchTerm<O> { + /// Wrap positions that are already known to be in range — sorting and + /// de-duplicating them into the canonical order. Private because nothing + /// outside can know the range holds: the generator's positions are masked + /// into `0..m` by construction, and every caller-supplied list goes + /// through [`from_positions`](Self::from_positions) instead. + fn normalised(mut positions: Vec<u16>) -> Self { + positions.sort_unstable(); + positions.dedup(); + Self { + positions, + _config: PhantomData, + } + } + + /// The transport byte encoding: each position as a little-endian `u16`, + /// in the canonical order [`positions`](Self::positions) holds them + /// (sorted ascending, no duplicates). See the + /// [module docs](self#byte-encodings) — what is stored and queried is the + /// position list; this is the frozen form a language binding carries + /// across the wasm/FFI boundary. The inverse of + /// [`from_bytes`](Self::from_bytes). + pub fn to_bytes(&self) -> Vec<u8> { + self.positions + .iter() + .flat_map(|p| p.to_le_bytes()) + .collect() + } + + /// The set Bloom-filter bit positions, sorted ascending, no duplicates. + pub fn positions(&self) -> &[u16] { + &self.positions + } + + /// Unwrap into the stored positions. + pub fn into_positions(self) -> Vec<u16> { + self.positions + } + + /// Whether this term's positions are a superset of `query`'s — the Bloom + /// containment check used to evaluate a match query (with the usual Bloom + /// false-positive rate; a query that generates tokens can never produce a + /// false negative). + /// + /// An empty `query` returns `false`: containment of zero positions is + /// vacuously true, which would turn an empty probe into a match-every-row + /// query. Term generation already refuses to build such a term + /// ([`TermError::EmptyTermText`]); this guards any other + /// (e.g. deserialized) source of an empty term. + pub fn contains(&self, query: &MatchTerm<O>) -> bool { + !query.positions.is_empty() + && query + .positions + .iter() + .all(|p| self.positions.binary_search(p).is_ok()) + } +} + +/// Rebuilding a term needs the [`MatchConfig`]: it fixes the filter size `m` +/// every genuine position is below, and a position outside it is a decoding +/// bug rather than a term. +impl<O: MatchConfig> MatchTerm<O> { + /// Rebuild a term from stored positions — the inverse of + /// [`positions`](Self::positions) / + /// [`into_positions`](Self::into_positions), for terms persisted + /// server-side. Sorts and de-duplicates, so any ordering is accepted; + /// the caller asserts (via `O`) that the positions were generated under + /// the same [`MatchConfig`], and that much is checked: a position at or + /// beyond `O`'s filter size `m` is rejected with + /// [`TermBytesError::MatchPositionOutOfRange`]. + pub fn from_positions(positions: Vec<u16>) -> Result<Self, TermBytesError> { + let filter_size = O::options().m; + for &position in &positions { + if u32::from(position) >= filter_size { + return Err(TermBytesError::MatchPositionOutOfRange { + position, + filter_size, + }); + } + } + Ok(Self::normalised(positions)) + } + + /// Decode the transport byte encoding — little-endian `u16` positions — + /// the inverse of [`to_bytes`](Self::to_bytes). Like + /// [`from_positions`](Self::from_positions), any ordering is accepted and + /// normalised, and positions outside `O`'s filter are rejected: without + /// that check a wrong-endian decoder on the other side of the FFI + /// boundary would produce a term that decodes cleanly and then silently + /// never matches. Rejects an odd-length buffer. + pub fn from_bytes(bytes: &[u8]) -> Result<Self, TermBytesError> { + if !bytes.len().is_multiple_of(2) { + return Err(TermBytesError::OddMatchTermLength(bytes.len())); + } + Self::from_positions( + bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(), + ) + } +} + +/// [`MatchTerm::from_bytes`] as a std conversion — the same decoder. +impl<O: MatchConfig> TryFrom<&[u8]> for MatchTerm<O> { + type Error = TermBytesError; + + fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> { + Self::from_bytes(bytes) + } +} + +impl<O> fmt::Debug for MatchTerm<O> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MatchTerm") + .field("positions", &self.positions) + .finish() + } +} + +impl<O> Clone for MatchTerm<O> { + fn clone(&self) -> Self { + Self::normalised(self.positions.clone()) + } +} + +impl<O> PartialEq for MatchTerm<O> { + fn eq(&self, other: &Self) -> bool { + self.positions == other.positions + } +} + +impl<O> Eq for MatchTerm<O> {} + +/// A [`PrfVisitor`] that folds a sequence of per-token PRF blocks into +/// Bloom-filter bit positions: `k` little-endian 2-byte slices of each block, +/// masked to the filter size. +/// +/// The PRF input for a match term is the *sequence* of tokens the text was +/// cut into (n-grams or words — see [`Tokenizer`]), and the backend evaluates +/// the PRF once per token. The resolved output therefore arrives as a +/// sequence of blocks, one per token, which is why this visitor implements +/// `visit_seq` rather than `visit_block`: it walks the per-token blocks and +/// turns each one into that token's `k` Bloom-filter bits. +struct BloomVisitor { + k: usize, + mask: u16, +} + +impl<P: Send + 'static> PrfVisitor<[u8; 32], P> for BloomVisitor { + type Value = Vec<u16>; + + fn visit_seq(self, seq: SeqAccess<[u8; 32], P>) -> Result<Self::Value, PrfVisitorError> { + // One node per token, in token order. Each node is the PRF output for + // that token alone; `BlockVisitor` unwraps it to the raw 32-byte block. + let mut positions: Vec<u16> = Vec::with_capacity(seq.len() * self.k); + for node in seq { + let block = node.visit(BlockVisitor)?; + // A token sets `k` bits of the filter. The block is 32 bytes and + // `k <= 16` (checked in `MatchOptions::validate`), so the `k` + // 2-byte slices are disjoint; masking to `m - 1` maps each u16 into + // the filter's `m` positions. The same token in a query text hits + // the same `k` positions, which is what `MatchTerm::contains` tests. + for i in 0..self.k { + let chunk = [block[2 * i], block[2 * i + 1]]; + positions.push(u16::from_le_bytes(chunk) & self.mask); + } + } + // The stored term is a set of positions: order and multiplicity carry + // no information, and sorting makes `contains` a binary search. + positions.sort_unstable(); + positions.dedup(); + Ok(positions) + } + + // A map-shaped input is not a token stream. + fn visit_map(self, _map: MapAccess<[u8; 32], P>) -> Result<Self::Value, PrfVisitorError> { + Err(PrfVisitorError::UnexpectedShape) + } +} + +/// Derive a match term. Synchronous — see [`equality`]: validation and +/// tokenization run before the PRF, the visitor folds blocks into positions. +fn match_term<O>( + prf: &HmacSha256Prf, + text: &str, + context: Context<'_>, + options: MatchOptions, +) -> Result<MatchTerm<O>, TermError> { + let mask = options.validate()?; + let tokens = tokenize::tokenize(text, options.tokenizer, options.downcase); + if tokens.is_empty() { + return Err(TermError::EmptyTermText); + } + let context = Context::pae(&[MATCH_DOMAIN, context.as_bytes()]); + + let positions = tokens + .prf_visit_with_context(prf, context, BloomVisitor { k: options.k, mask }) + .into_result() + .map_err(TermError::from_prf)?; + // Every position came out of the visitor masked to `m - 1`, so the range + // check `from_positions` applies is already satisfied by construction. + Ok(MatchTerm::normalised(positions)) +} + +/// A match term of any text source, generated under `O`'s options. Under +/// the local HMAC backend, derived during the synchronous build — the +/// returned [`Pending`] carries no requests (tokenize makes the one +/// necessary copy of the text). +impl<'c, S, K, O, T> Term<S, K, NonEmpty<T>> for MatchTerm<O> +where + S: AsRef<str>, + O: MatchConfig, + T: IntoPrfContext<'c>, +{ + // Derived locally: no data key, no descriptor. + + fn encrypt_from<'a>( + source: &S, + cipher: &'a KeysetCipher<'_, K>, + context: NonEmpty<T>, + ) -> Pending<'a, Self, K> + where + Self: 'a, + { + let context = context.into_prf_context().into_owned(); + let term = + match_term(cipher.prf(), source.as_ref(), context, O::options()).map_err(Error::from); + Pending::ready(cipher, term) + } +} + +// ============================================================================= +// ORE / OPE +// ============================================================================= + +/// An order-revealing (CLLW ORE) index term for a source value of type `T`. +/// Wraps the CLLW ciphertext ([`CllwOreEncrypt::Output`]); comparisons order +/// like the plaintexts. +/// +/// The type parameter is the *source* type: `OreTerm<u64>` in a record type +/// declares "this field is the ORE term of a `u64`". +pub struct OreTerm<T: CllwOreEncrypt>(T::Output); + +/// An order-preserving (CLLW OPE) index term for a source value of type `T` +/// (see [`OreTerm`]). The wrapped ciphertext compares with plain +/// lexicographic byte order. +pub struct OpeTerm<T: CllwOpeEncrypt>(T::Output); + +/// Index terms are one-way: decryption passes over them. `Decryptable` and +/// `DecryptField` say so, which is how a derived record finds its ciphertext +/// field among them without being told. +macro_rules! index_term { + ($ty:ty $(, $param:ident: $bound:path)?) => { + impl<$($param: $bound)?> Decryptable for $ty { + const DECRYPTABLE: bool = false; + } + impl<P, Ctx $(, $param: $bound)?> DecryptField<P, Ctx> for $ty { + fn decryption_field<K: 'static>(self, _: Ctx) -> Option<Decryption<P, K>> { + None + } + } + }; +} + +index_term!(EqualityTerm); +index_term!(MatchTerm<O>, O: MatchConfig); +index_term!(OreTerm<T>, T: CllwOreEncrypt); +index_term!(OpeTerm<T>, T: CllwOpeEncrypt); + +macro_rules! term_wrapper { + ($name:ident, $bound:ident) => { + impl<T: $bound> $name<T> { + /// Wrap an already-generated CLLW ciphertext. + pub fn new(output: T::Output) -> Self { + Self(output) + } + + /// The wrapped CLLW ciphertext. + pub fn inner(&self) -> &T::Output { + &self.0 + } + + /// Unwrap into the CLLW ciphertext. + pub fn into_inner(self) -> T::Output { + self.0 + } + + /// Decode a term from its frozen byte encoding — the raw CLLW + /// ciphertext bytes, the inverse of [`as_bytes`](Self::as_bytes) + /// — for terms persisted server-side. Structural only (length + /// checks); the caller asserts the bytes were generated for this + /// source type `T` and under the same context. + pub fn from_bytes(bytes: &[u8]) -> Result<Self, TermBytesError> + where + for<'a> T::Output: TryFrom<&'a [u8]>, + { + T::Output::try_from(bytes) + .map(Self) + .map_err(|_| TermBytesError::MalformedCllwCiphertext(bytes.len())) + } + } + + /// The inherent `from_bytes` as a std conversion — the same decoder. + impl<T: $bound> TryFrom<&[u8]> for $name<T> + where + for<'a> T::Output: TryFrom<&'a [u8]>, + { + type Error = TermBytesError; + + fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> { + Self::from_bytes(bytes) + } + } + + impl<T: $bound> $name<T> + where + T::Output: AsRef<[u8]>, + { + /// The frozen byte encoding: the raw CLLW ciphertext bytes, + /// unframed — the same *shape* the EQL layer hex-encodes, but + /// not comparable with rows it wrote (see the + /// [module docs](self#byte-encodings)). + pub fn as_bytes(&self) -> &[u8] { + self.0.as_ref() + } + + /// Owned copy of [`as_bytes`](Self::as_bytes). + pub fn to_bytes(&self) -> Vec<u8> { + self.0.as_ref().to_vec() + } + } + + /// The frozen byte encoding — the same bytes as the inherent + /// `as_bytes`. + impl<T: $bound> AsRef<[u8]> for $name<T> + where + T::Output: AsRef<[u8]>, + { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } + } + + impl<T: $bound> fmt::Debug for $name<T> + where + T::Output: fmt::Debug, + { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple(stringify!($name)).field(&self.0).finish() + } + } + + impl<T: $bound> Clone for $name<T> + where + T::Output: Clone, + { + fn clone(&self) -> Self { + Self(self.0.clone()) + } + } + + impl<T: $bound> PartialEq for $name<T> + where + T::Output: PartialEq, + { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } + } + + impl<T: $bound> Eq for $name<T> where T::Output: Eq {} + + impl<T: $bound> PartialOrd for $name<T> + where + T::Output: PartialOrd, + { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + self.0.partial_cmp(&other.0) + } + } + + impl<T: $bound> Ord for $name<T> + where + T::Output: Ord, + { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.cmp(&other.0) + } + } + }; +} + +term_wrapper!(OreTerm, CllwOreEncrypt); +term_wrapper!(OpeTerm, CllwOpeEncrypt); + +/// A [`PrfVisitor`] that carries the plaintext in and hands the CLLW ORE +/// ciphertext out. The PRF block becomes the CLLW [`Key`](cllw_ore::Key) +/// *inside* `visit_block` and dies there: the block arrives by value and is +/// copied into the `ZeroizeOnDrop` key (`[u8; 32]` is `Copy`, so `Key::from` +/// wipes its copy and this visitor wipes the one it still holds), the value +/// is encrypted, and only the ciphertext leaves. No key is ever returned to +/// the caller. +/// +/// This is the shape a 2-party PRF needs: the caller supplies a PRF input +/// (the context) and receives a term, and where the key comes from — or +/// whether one exists at all — is the visitor's business. Swapping the +/// backend for one that returns per-prefix PRF outputs instead of a key +/// changes this visitor, not its callers. +/// +/// The plaintext is owned (`T: 'static`) because the visitor outlives the +/// call under an asynchronous backend; `Send` for the same reason. CLLW +/// failures surface through the visitor's `Value` rather than +/// [`PrfVisitorError`] so they keep their own error type. +struct OreVisitor<T>(T); + +impl<T, P> PrfVisitor<[u8; 32], P> for OreVisitor<T> +where + T: CllwOreEncrypt + Send + 'static, + T::Output: Send + 'static, + P: Send + 'static, +{ + type Value = Result<T::Output, cllw_ore::Error>; + + fn visit_block(self, mut block: [u8; 32]) -> Result<Self::Value, PrfVisitorError> { + let key = cllw_ore::Key::from(block); + block.zeroize(); + Ok(self.0.encrypt(&key)) + } +} + +/// The OPE twin of [`OreVisitor`]: same key handling, produces a CLLW OPE +/// ciphertext (byte order is plaintext order). +struct OpeVisitor<T>(T); + +impl<T, P> PrfVisitor<[u8; 32], P> for OpeVisitor<T> +where + T: CllwOpeEncrypt + Send + 'static, + T::Output: Send + 'static, + P: Send + 'static, +{ + type Value = Result<T::Output, cllw_ore::Error>; + + fn visit_block(self, mut block: [u8; 32]) -> Result<Self::Value, PrfVisitorError> { + let key = cllw_ore::Key::from(block); + block.zeroize(); + Ok(self.0.encrypt_ope(&key)) + } +} + +/// Derive an ORE term: PRF of the encoded context under a PAE-encoded +/// `[domain, context]` framing — the same framing every other term kind uses +/// (see the module docs), so no reimplementation of this derivation can +/// collide with an equality or match derivation. Only the context enters the +/// PRF — never the plaintext, which rides in the visitor and is encrypted +/// there. Deterministic, so write-time and query-time terms agree; under a +/// 2-party PRF backend this derivation is a visible ZeroKMS event. +fn ore<T>(prf: &HmacSha256Prf, value: T, context: Context<'_>) -> Result<OreTerm<T>, TermError> +where + T: CllwOreEncrypt + Send + 'static, + T::Output: Send + 'static, +{ + let context_bytes = context.as_bytes(); + context_bytes + .prf_visit_with_context( + prf, + Context::pae(&[ORE_KEY_DOMAIN, context_bytes]), + OreVisitor(value), + ) + .into_result() + .map_err(TermError::from_prf)? + .map(OreTerm) + .map_err(TermError::Ore) +} + +/// Derive an OPE term — as [`ore`], under the OPE domain so the two schemes +/// never share a key. +fn ope<T>(prf: &HmacSha256Prf, value: T, context: Context<'_>) -> Result<OpeTerm<T>, TermError> +where + T: CllwOpeEncrypt + Send + 'static, + T::Output: Send + 'static, +{ + let context_bytes = context.as_bytes(); + context_bytes + .prf_visit_with_context( + prf, + Context::pae(&[OPE_KEY_DOMAIN, context_bytes]), + OpeVisitor(value), + ) + .into_result() + .map_err(TermError::from_prf)? + .map(OpeTerm) + .map_err(TermError::Ore) +} + +/// An ORE term of any [`CllwOreEncrypt`] source. Under the local HMAC +/// backend, derived during the synchronous build — the returned [`Pending`] +/// carries no requests. +impl<'c, S, K, T> Term<S, K, NonEmpty<T>> for OreTerm<S> +where + S: CllwOreEncrypt + Clone + Send + 'static, + S::Output: Send + 'static, + T: IntoPrfContext<'c>, +{ + // Derived locally: no data key, no descriptor. + + fn encrypt_from<'a>( + source: &S, + cipher: &'a KeysetCipher<'_, K>, + context: NonEmpty<T>, + ) -> Pending<'a, Self, K> + where + Self: 'a, + { + let context = context.into_prf_context().into_owned(); + let term = ore(cipher.prf(), source.clone(), context).map_err(Error::from); + Pending::ready(cipher, term) + } +} + +/// An OPE term of any [`CllwOpeEncrypt`] source. Under the local HMAC +/// backend, derived during the synchronous build — the returned [`Pending`] +/// carries no requests. +impl<'c, S, K, T> Term<S, K, NonEmpty<T>> for OpeTerm<S> +where + S: CllwOpeEncrypt + Clone + Send + 'static, + S::Output: Send + 'static, + T: IntoPrfContext<'c>, +{ + // Derived locally: no data key, no descriptor. + + fn encrypt_from<'a>( + source: &S, + cipher: &'a KeysetCipher<'_, K>, + context: NonEmpty<T>, + ) -> Pending<'a, Self, K> + where + Self: 'a, + { + let context = context.into_prf_context().into_owned(); + let term = ope(cipher.prf(), source.clone(), context).map_err(Error::from); + Pending::ready(cipher, term) + } +} + +// ============================================================================= +// Term generation on the cipher +// ============================================================================= + +/// Descriptor term generation, for call sites that want one term rather than +/// a whole record: query builders probing an index, re-indexers, tests of a +/// single scheme. +/// +/// Each returns a [`Pending`], the same carrier the record path hands back, +/// so probes combine ([`Pending::zip`], [`Pending::all`]) and a batch of +/// them settles as one. Under the local HMAC backend a term is derived +/// during the synchronous build and the pending carries no requests — +/// awaiting it does no I/O — but that is the backend's property, not the +/// API's: a backend that derives terms at ZeroKMS settles them the way it +/// settles data keys, through the same pending. +/// +/// # These are the query-probe path +/// +/// A term derived here is bound to the descriptor you pass and to nothing +/// else: it builds no [`Descriptor`](crate::Descriptor), makes no ZeroKMS +/// request, and has no relation to the ciphertext of the field it indexes. +/// Nothing checks that the two agree, and the two mistakes fail differently — +/// a ciphertext under the wrong context is refused at first read, while a +/// term under the wrong context is a valid term in another domain that +/// matches nothing, forever, with no error anywhere. +/// +/// So derive a term here to *query*: a probe has no ciphertext to agree with, +/// and needs the field's context because that is what it is matching against. +/// A term that is going to be **stored** should come from a target instead, +/// where the one context the target is handed reaches the ciphertext and the +/// term beside it alike, and giving either a context of its own is written +/// in the declaration rather than plumbed (ADR-0004). A derived record does +/// this per field and cannot get it wrong; a hand-written target can still +/// put a subtree under its own context, but has to say so. The bytes are +/// identical either way; what differs is whether anything holds the two in +/// agreement. +/// +/// The descriptor is a context +/// as the target-directed leaves take it — a [`NonEmpty<T>`]: +/// `nonempty!("users/email")`, `NonEmpty::new(column)?`, +/// `nonempty!("users/email").with(row_id)` — and each method is +/// byte-identical to that path for the same descriptor, so a term generated +/// here compares against one generated by `encrypt_into_with_context`. +impl<K> KeysetCipher<'_, K> { + /// Generate an equality (exact-match) term for `value` under the field + /// `descriptor`. Deterministic: the same value + descriptor always yields + /// the same term, at write time and at query time. Byte-identical to + /// `value.encrypt_into_with_context(&keyset, descriptor)` into an + /// `EqualityTerm`. + pub fn equality_term<'c, T>( + &self, + value: T, + descriptor: NonEmpty<impl IntoPrfContext<'c>>, + ) -> Pending<'_, EqualityTerm, K> + where + T: PrfValue, + { + let term = equality(self.prf(), value, descriptor.into_prf_context()); + Pending::ready(self, term.map_err(Error::from)) + } + + /// Generate a match (full-text) term for `text` under the field + /// `descriptor` and the type-level config `O`: tokenize locally, PRF each + /// token, fold the outputs into Bloom-filter bit positions. + /// + /// The config is a *type* parameter (not runtime options) so write-time + /// and query-time terms agree by construction — a term generated under + /// one config cannot be compared against a term generated under another, + /// which would otherwise silently return false negatives. Byte-identical + /// to the target-directed path for the same `O`. For custom options, + /// define a marker type implementing [`MatchConfig`]. + /// + /// The same call serves both write time (index the stored text) and query + /// time (index the probe text, then test [`MatchTerm::contains`] + /// server-side). + /// + /// Fails with [`TermError::EmptyTermText`] — as + /// [`Error::Term`], once awaited — when the text + /// yields no tokens: empty or separator-only text, or an n-gram probe + /// shorter than the gram length (which could never match; see + /// [`Tokenizer::Ngram`]). + pub fn match_terms<'c, O>( + &self, + text: &str, + descriptor: NonEmpty<impl IntoPrfContext<'c>>, + ) -> Pending<'_, MatchTerm<O>, K> + where + O: MatchConfig + MaybeSend, + { + let term = match_term( + self.prf(), + text, + descriptor.into_prf_context(), + O::options(), + ); + Pending::ready(self, term.map_err(Error::from)) + } + + /// Generate an order-revealing (CLLW ORE) term for a range-queryable value + /// under the field `descriptor`. The PRF input is the descriptor alone — + /// the plaintext never enters the PRF; it travels in the visitor, which + /// derives the per-descriptor CLLW key from the PRF block and encrypts + /// under it in one step. The key never leaves the visitor. + /// + /// Supported inputs: `u16`/`u32`/`u64`/`u128`, `&'static str`, `String`, + /// `Vec<u8>` (via [`CllwOreEncrypt`]). The value must be owned + /// (`'static`) because the visitor carries it; pass a `String` for + /// borrowed text. Returns the raw CLLW ciphertext; the target-directed + /// path wraps the same bytes in [`OreTerm`]. + pub fn ore_term<'c, T>( + &self, + value: T, + descriptor: NonEmpty<impl IntoPrfContext<'c>>, + ) -> Pending<'_, T::Output, K> + where + T: CllwOreEncrypt + Send + 'static, + T::Output: Send + 'static, + { + let term = ore(self.prf(), value, descriptor.into_prf_context()).map(OreTerm::into_inner); + Pending::ready(self, term.map_err(Error::from)) + } + + /// Generate an order-preserving (CLLW OPE) term: ciphertexts compare with + /// plain lexicographic byte order, no custom comparator required. + /// Encrypt-only — pair with the record ciphertext for round-trips. Key + /// handling and input bounds as for [`ore_term`](Self::ore_term). + pub fn ope_term<'c, T>( + &self, + value: T, + descriptor: NonEmpty<impl IntoPrfContext<'c>>, + ) -> Pending<'_, T::Output, K> + where + T: CllwOpeEncrypt + Send + 'static, + T::Output: Send + 'static, + { + let term = ope(self.prf(), value, descriptor.into_prf_context()).map(OpeTerm::into_inner); + Pending::ready(self, term.map_err(Error::from)) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use vitaminc_prf::PrfKeyInit; + use vitaminc_protected::Protected; + + use super::*; + + /// The Bloom fold reads a token *stream*: handed a map-shaped PRF input + /// it refuses, rather than folding the entries' blocks as if they were + /// tokens. + #[test] + fn the_bloom_fold_refuses_a_map_shaped_input() { + let prf = HmacSha256Prf::new(Protected::new([7; 32])); + let input = BTreeMap::from([("a".to_string(), "alice".to_string())]); + + let result = input + .prf_visit_with_context(&prf, (), BloomVisitor { k: 3, mask: 255 }) + .into_result(); + assert!( + matches!( + result, + Err(PrfError::Visitor(PrfVisitorError::UnexpectedShape)) + ), + "{result:?}" + ); + } +} diff --git a/packages/stack-encrypt/src/sem/tokenize.rs b/packages/stack-encrypt/src/sem/tokenize.rs new file mode 100644 index 000000000..19ac3c119 --- /dev/null +++ b/packages/stack-encrypt/src/sem/tokenize.rs @@ -0,0 +1,131 @@ +//! Tokenization for match (full-text) index terms. +//! +//! Tokens are derived locally from the plaintext *before* any PRF is applied; +//! only the PRF outputs (Bloom-filter bit positions) leave the process. +//! +//! Semantics mirror the v1 match indexer (`cipherstash-client`'s +//! `encryption::text::Tokenizer`) so v2 match queries behave like existing +//! ones: n-grams yield nothing for text shorter than the gram length, and +//! `Standard` splits on the same separator set. The one deliberate divergence +//! is that empty tokens are dropped (v1 keeps the empty strings its separator +//! split produces, which only add noise bits to every filter). + +/// How text is split into tokens before each token is run through the PRF. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tokenizer { + /// Sliding character n-grams of the given length over the whole text + /// (whitespace included). Text shorter than `length` yields **no tokens**, + /// exactly like the v1 match indexer — so a probe shorter than the gram + /// length is rejected by + /// [`match_terms`](crate::KeysetCipher::match_terms) rather than + /// silently never matching. This is the default, matching the existing + /// match indexer's 3-gram configuration. + Ngram { length: usize }, + /// Split on the v1 match indexer's separator set — space, comma, + /// semicolon, colon and exclamation mark — one token per run of + /// non-separator characters. + Standard, +} + +/// The separators `Tokenizer::Standard` splits on, as in the v1 match +/// indexer's `process_standard`. +const STANDARD_SEPARATORS: [char; 5] = [' ', ',', ';', ':', '!']; + +impl Default for Tokenizer { + fn default() -> Self { + Self::Ngram { length: 3 } + } +} + +/// Split `text` into tokens. `downcase` lower-cases the text first so matches +/// are case-insensitive. +/// +/// May return no tokens (empty text, separator-only text, or text shorter +/// than the n-gram length); [`match_terms`] rejects that case so an empty +/// term can never reach a query. +/// +/// [`match_terms`]: crate::StackCipher::match_terms +pub(crate) fn tokenize(text: &str, tokenizer: Tokenizer, downcase: bool) -> Vec<String> { + let text = if downcase { + text.to_lowercase() + } else { + text.to_string() + }; + + match tokenizer { + Tokenizer::Standard => text + .split(STANDARD_SEPARATORS) + .filter(|token| !token.is_empty()) + .map(str::to_string) + .collect(), + Tokenizer::Ngram { length } => { + let chars: Vec<char> = text.chars().collect(); + // As in the v1 indexer's `process_ngram`: shorter than one gram + // means no tokens (never a partial or whole-text token, which + // could not match any stored gram). + if chars.len() < length { + Vec::new() + } else { + chars.windows(length).map(|w| w.iter().collect()).collect() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ngram_tokenizes_sliding_windows() { + assert_eq!( + tokenize("hello", Tokenizer::Ngram { length: 3 }, true), + vec!["hel", "ell", "llo"] + ); + } + + #[test] + fn ngram_gram_length_text_yields_one_token() { + assert_eq!( + tokenize("hey", Tokenizer::Ngram { length: 3 }, true), + vec!["hey"] + ); + } + + #[test] + fn ngram_short_text_yields_no_tokens() { + // Mirrors the v1 indexer: a sub-gram-length probe can never match a + // stored gram, so it must not produce a token at all. + assert!(tokenize("hi", Tokenizer::Ngram { length: 3 }, true).is_empty()); + } + + #[test] + fn ngram_empty_text_yields_no_tokens() { + assert!(tokenize("", Tokenizer::Ngram { length: 3 }, true).is_empty()); + } + + #[test] + fn standard_splits_on_the_v1_separator_set() { + assert_eq!( + tokenize("Hello, World! again", Tokenizer::Standard, true), + vec!["hello", "world", "again"] + ); + } + + #[test] + fn standard_drops_empty_tokens() { + assert!(tokenize(" ,;:! ", Tokenizer::Standard, true).is_empty()); + } + + #[test] + fn standard_does_not_split_on_other_whitespace() { + // The v1 separator set is exactly ' ', ',', ';', ':', '!' — tabs and + // newlines are part of the token, as in v1. + assert_eq!(tokenize("a\tb", Tokenizer::Standard, true), vec!["a\tb"]); + } + + #[test] + fn downcase_can_be_disabled() { + assert_eq!(tokenize("Hi", Tokenizer::Standard, false), vec!["Hi"]); + } +} diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs new file mode 100644 index 000000000..614371984 --- /dev/null +++ b/packages/stack-encrypt/src/target/context.rs @@ -0,0 +1,248 @@ +//! The contexts a target declares. +//! +//! A target's associated `Context` is what a caller hands `encrypt_as` and +//! `decrypt_as` alongside the value. The types here are the core-owned ones: +//! each holds the parts view of a nonempty context — the one tree both the +//! AEAD and the PRF encode from — so the structured identity of its +//! descriptor survives the trip into a boxed operation description. A +//! record that stores its own identifier declares `NonEmpty<T>` instead, +//! and a target whose declaration carries every context it needs declares +//! `()`. +use crate::{ContextPiece, Descriptor, Error, IntoContext, MaybeEmpty, NonEmpty}; + +/// Prove a context nonempty at the point it is used. The core-owned types +/// are nonempty by construction, so for them this cannot fail; the one +/// helper keeps the error mapping in one place. +pub(super) fn nonempty<T: MaybeEmpty>(value: T) -> Result<NonEmpty<T>, Error> { + NonEmpty::new(value).map_err(|e| Error::Other(Box::new(e))) +} + +/// An owned, validated context for a target that accepts any Vitamin C +/// context and derives both ciphertext and terms from it. +/// +/// Built from a `NonEmpty<T>` (or a bare integer), it holds the parts view +/// of that context. The AEAD and the PRF encode the same tree to the same +/// bytes, so one tree serves both derivations and the descriptor's +/// structured identity is preserved. Concrete records declare their own +/// context type instead. +#[derive(Clone, Debug)] +pub struct CallerContext(ContextPiece<'static>); +impl<'c, T> From<NonEmpty<T>> for CallerContext +where + T: IntoContext<'c>, +{ + fn from(context: NonEmpty<T>) -> Self { + Self(context.into_context().into_owned()) + } +} +impl MaybeEmpty for CallerContext { + fn is_empty(&self) -> bool { + false + } +} +impl<'a> IntoContext<'a> for CallerContext { + fn into_context(self) -> ContextPiece<'a> { + self.0 + } +} +impl CallerContext { + pub(super) fn validated(self) -> Result<NonEmpty<Self>, Error> { + nonempty(self) + } + /// The own context `own`, extended by this caller context: the field's + /// literal is the prefix, this context the extension, exactly as a + /// `struct = T` derive composes them — `("users/age", id)`. The own + /// context is never discarded. + pub fn extend(self, own: NonEmpty<&'static str>) -> Self { + own.with(self).into() + } +} + +/// An owned nonempty context for ciphertext-only operations. +/// +/// Since vitaminc 0.5 every context type implements one trait, +/// [`IntoContext`], and the AEAD and PRF derivations are both blankets over +/// it, so this type accepts exactly the contexts a [`CallerContext`] does. +/// It is kept as a distinct declaration because it says something a +/// `CallerContext` does not: the record it is declared on derives no terms. +/// A derived record whose fields are all ciphertexts declares it with +/// `#[stash(context_type = AeadContext)]`, and then accepts the same +/// contexts the canonical [`StackCipherText`](crate::StackCipherText) path +/// does. +#[derive(Clone, Debug)] +pub struct AeadContext(ContextPiece<'static>); +impl<'a, T: IntoContext<'a>> From<NonEmpty<T>> for AeadContext { + fn from(value: NonEmpty<T>) -> Self { + Self(value.into_context().into_owned()) + } +} +impl From<CallerContext> for AeadContext { + fn from(value: CallerContext) -> Self { + Self(value.0) + } +} +impl MaybeEmpty for AeadContext { + fn is_empty(&self) -> bool { + false + } +} +impl<'a> IntoContext<'a> for AeadContext { + fn into_context(self) -> ContextPiece<'a> { + self.0 + } +} +impl AeadContext { + pub(super) fn validated(self) -> Result<NonEmpty<Self>, Error> { + nonempty(self) + } + /// The own context `own`, extended by this caller context, as + /// [`CallerContext::extend`] does for a record that derives terms: the + /// field's literal is the prefix, this context the extension. + pub fn extend(self, own: NonEmpty<&'static str>) -> Self { + own.with(self).into() + } +} + +/// A caller's context of either kind, extending a field's own context: what +/// [`Encryption::extend`](super::Encryption::extend) asks of the context a +/// subtree is run under. An own context is a `NonEmpty<&'static str>` — the +/// derive emits a `nonempty!(..)` for a literal — so an empty one is refused +/// at compile time, and extending cannot fail. +/// +/// Sealed: the two core-owned types are the two kinds, and a context that +/// extends is one whose encodings the core built. +pub trait Extends: sealed::Sealed + Sized { + /// The own context `own`, extended by this one. + fn extend(self, own: NonEmpty<&'static str>) -> Self; +} +mod sealed { + pub trait Sealed {} + impl Sealed for super::CallerContext {} + impl Sealed for super::AeadContext {} +} +impl Extends for CallerContext { + fn extend(self, own: NonEmpty<&'static str>) -> Self { + CallerContext::extend(self, own) + } +} +impl Extends for AeadContext { + fn extend(self, own: NonEmpty<&'static str>) -> Self { + AeadContext::extend(self, own) + } +} + +/// The optional extension a record whose fields already declare their own +/// contexts accepts from its caller. +/// +/// `()` (the default) leaves the declared contexts as they are; a nonempty +/// value extends each of them, the way a caller's context extends a field's +/// own. This is what a `struct = T` derive without a `context_field` +/// declares. +#[derive(Clone, Debug, Default)] +pub struct DeclaredContext(Option<CallerContext>); +impl From<()> for DeclaredContext { + fn from(_: ()) -> Self { + Self::default() + } +} +impl From<CallerContext> for DeclaredContext { + fn from(value: CallerContext) -> Self { + Self(Some(value)) + } +} +impl<'a, T: IntoContext<'a>> From<NonEmpty<T>> for DeclaredContext { + fn from(value: NonEmpty<T>) -> Self { + Self(Some(value.into())) + } +} +impl DeclaredContext { + /// The context one field is derived under: its own `own`, extended by + /// the caller's context if one was given — `"users/age"` as it is under + /// `()`, `("users/age", id)` under a caller's `id`. + pub fn under(self, own: NonEmpty<&'static str>) -> CallerContext { + match self.0 { + Some(caller) => caller.extend(own), + None => own.into(), + } + } +} + +/// What a caller may assert about a record that stores its context. +/// +/// A `#[stash(context_field)]` record carries its identifier in storage, and +/// decryption reads the context from there. The default asks only that the +/// stored value be nonempty; a `NonEmpty<T>` asks that it also equal the +/// destination the caller believes it is opening. Either way the check runs +/// before any key is retrieved. +/// +/// # The default accepts whatever the record stores +/// +/// That is deliberate, and the reason is the column migration flow — add +/// `email_encrypted`, migrate, drop `email`, rename `email_encrypted` to +/// `email`. Every row written before the rename still stores the old +/// identifier, so a strict check would reject all of them at the first read +/// afterwards. +/// +/// The consequence is worth stating rather than discovering: an identifier +/// that must survive renames cannot also enforce placement. A whole, +/// self-consistent record moved from one column to another opens cleanly — a +/// confused deputy, to be caught by the caller passing the identifier it +/// expects, not by this type's default. What is *not* at risk is the key: the +/// descriptor is HMAC'd into the tag, so altering a stored identifier makes +/// the retrieve fail rather than succeed, and nobody reaches a key they are +/// not entitled to. See ADR-0004. +#[derive(Clone, Debug)] +pub struct ExpectedContext<T>(Option<NonEmpty<T>>); +impl<T> Default for ExpectedContext<T> { + fn default() -> Self { + Self(None) + } +} +impl<T> From<()> for ExpectedContext<T> { + fn from(_: ()) -> Self { + Self::default() + } +} +impl<T> From<NonEmpty<T>> for ExpectedContext<T> { + fn from(value: NonEmpty<T>) -> Self { + Self(Some(value)) + } +} +impl<'c, T: MaybeEmpty + PartialEq + IntoContext<'c>> ExpectedContext<T> { + /// Check the stored context against this expectation and prove it + /// nonempty, yielding the context the record is opened under. + /// + /// # Errors + /// + /// [`Error::ContextMismatch`] if an expected context was given and the + /// stored one differs from it; the error carries the stored context's + /// descriptor. Otherwise fails if the stored context is empty. A stored + /// context is data the record was handed, not something the cipher has + /// authenticated yet: both checks happen before any key is requested. + pub fn validate(self, stored: T) -> Result<NonEmpty<T>, Error> { + if let Some(expected) = self.0 { + if expected.into_inner() != stored { + let stored = Descriptor::from_piece(&stored.into_context()); + return Err(Error::ContextMismatch { stored }); + } + } + nonempty(stored) + } +} + +macro_rules! integer_contexts { + ($($ty:ty),*) => {$ ( + impl From<$ty> for CallerContext { + fn from(value: $ty) -> Self { Self::from(NonEmpty::<$ty>::from(value)) } + } + impl From<$ty> for AeadContext { + fn from(value: $ty) -> Self { Self::from(NonEmpty::<$ty>::from(value)) } + } + impl From<$ty> for DeclaredContext { + fn from(value: $ty) -> Self { Self::from(NonEmpty::<$ty>::from(value)) } + } + )*}; +} +// A bare integer is a context in its own right (see `CONTEXT.md`): it needs +// no `NonEmpty` proof, so it converts directly. +integer_contexts!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128); diff --git a/packages/stack-encrypt/src/target/core.rs b/packages/stack-encrypt/src/target/core.rs new file mode 100644 index 000000000..5817a8a55 --- /dev/null +++ b/packages/stack-encrypt/src/target/core.rs @@ -0,0 +1,140 @@ +//! Canonical execution shared by the cipher-directed and declaration APIs. +use super::{CipherScope, Pending, Request, Responses}; +use crate::cipher::{bind_keys, PendingStackCipherText, StackDecipher}; +use crate::{Descriptor, Error, KeysetCipher, StackCipher, StackCipherText}; +use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad, IntoContext}; +use vitaminc_protected::NonEmpty; + +/// Internal term operation. It is deliberately inaccessible to target authors. +pub(crate) trait Term<S, K, Ctx>: Sized { + fn encrypt_from<'a>( + source: &S, + cipher: &'a KeysetCipher<'_, K>, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Self: 'a; +} + +pub(crate) fn encrypt_native<'a, 'c, S: Encrypt + Clone, K, T: IntoContext<'c>>( + source: &S, + cipher: &'a KeysetCipher<'_, K>, + context: NonEmpty<T>, +) -> Pending<'a, StackCipherText, K> { + let context = context.into_context(); + let descriptor = Descriptor::from_piece(&context); + if let Err(error) = descriptor.check() { + return Pending::failed(cipher, error); + } + let aad = context.into_aad().into_owned(); + match source.clone().encrypt_with_aad(cipher, aad) { + Ok(tree) => seal_pending(cipher, tree, descriptor), + Err(_) => Pending::ready(cipher, Err(Error::Aead)), + } +} +pub(crate) fn open_native<'a, 'c, P: Decrypt<'static> + 'static, K, T: IntoContext<'c>>( + tree: StackCipherText, + cipher: &'a StackCipher<K>, + context: NonEmpty<T>, +) -> Pending<'a, P, K> { + let context = context.into_context(); + let descriptor = Descriptor::from_piece(&context); + // Fast path, as in `seal_pending`; `dispatch` is the gate. + if let Err(e) = descriptor.check() { + return Pending::ready(cipher, Err(e)); + } + let requests = retrieve_requests(&tree, &descriptor); + let aad = context.into_aad().into_owned(); + Pending::request(cipher, requests, move |responses| { + let decipher = decipher_from_responses(tree, responses)?; + P::decrypt_with_aad(decipher, aad).map_err(Error::from) + }) +} +pub(crate) fn seal_pending<'a, K>( + cipher: &'a KeysetCipher<'_, K>, + tree: PendingStackCipherText, + descriptor: Descriptor, +) -> Pending<'a, StackCipherText, K> { + // Fast path: refuse an over-long descriptor before a single request + // exists, not after one per leaf has been built. `dispatch` is the gate + // proper, and checks every request's descriptor. + if let Err(e) = descriptor.check() { + return Pending::ready(cipher, Err(e)); + } + let keyset_id = cipher.keyset_id(); + let requests = std::iter::repeat_with(|| Request::generate_under(descriptor.clone())) + .take(tree.key_count()) + .collect(); + Pending::request(cipher, requests, move |responses| { + let mut keys = responses.drain_generated(); + tree.seal_with(keyset_id, &mut keys).map_err(Error::from) + }) +} + +pub(crate) fn decipher_pending<'a, K>( + scope: impl CipherScope<'a, K>, + ciphertext: StackCipherText, + descriptor: Descriptor, +) -> Pending<'a, StackDecipher, K> { + // Fast path, as in `seal_pending`; `dispatch` is the gate. + if let Err(e) = descriptor.check() { + return Pending::ready(scope, Err(e)); + } + let requests = retrieve_requests(&ciphertext, &descriptor); + Pending::request(scope, requests, move |responses| { + decipher_from_responses(ciphertext, responses) + }) +} + +fn decipher_from_responses( + ciphertext: StackCipherText, + responses: &mut Responses, +) -> Result<StackDecipher, Error> { + let mut keys = responses.drain_retrieved(); + // Too few keys for the tree, or keys left over once it is bound, both + // mean `retrieve_requests` and `bind_keys` disagreed about the tree's + // shape: a composition bug in this module, not a data error — so + // `ResponseShape`, never `Aead`, which would read as tampering. + let keyed = bind_keys(ciphertext, &mut keys).map_err(|_| Error::ResponseShape)?; + if keys.next().is_some() { + return Err(Error::ResponseShape); + } + Ok(StackDecipher::over(keyed)) +} + +fn retrieve_requests(ciphertext: &StackCipherText, descriptor: &Descriptor) -> Vec<Request> { + let mut out = Vec::new(); + collect_retrieve_requests(ciphertext, descriptor, &mut out); + out +} + +fn collect_retrieve_requests( + ciphertext: &StackCipherText, + descriptor: &Descriptor, + out: &mut Vec<Request>, +) { + match ciphertext { + CipherText::Single(leaf) + | CipherText::None(leaf) + | CipherText::EmptySequence(leaf) + | CipherText::EmptyMap(leaf) => { + out.push(Request::retrieve_under( + *leaf.iv(), + leaf.tag().to_vec(), + descriptor.clone(), + leaf.keyset_id(), + )); + } + CipherText::Sequence(items) => { + for item in items { + collect_retrieve_requests(item, descriptor, out); + } + } + CipherText::Map(entries) => { + for (_, value) in entries { + collect_retrieve_requests(value, descriptor, out); + } + } + CipherText::Passthrough(_) => {} + } +} diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs new file mode 100644 index 000000000..29b8b9736 --- /dev/null +++ b/packages/stack-encrypt/src/target/mod.rs @@ -0,0 +1,96 @@ +//! Targets declare operations; the cipher executes them through Vitamin C. +//! +//! [`EncryptFrom<S>`] describes ciphertext and/or term operations and has an +//! associated context type. It does not receive plaintext or a cipher. The +//! cipher's `encrypt_as` executes that description, returning the existing +//! batched [`Pending`]. [`DecryptInto<P>`] inspects stored output and describes +//! opening it through `P::Decrypt`. +//! +//! # Records and context +//! +//! The derives compose each semantic field's declaration. `#[stash(context_field)]` +//! on an identifier of type `T` makes the encryption context `NonEmpty<T>` and +//! stores its inner value. Opening takes an [`ExpectedContext`]: by default it checks only that the stored identifier is nonempty and opens under it as stored; a `NonEmpty<T>` also requires it to equal the destination the caller names, and a mismatch is refused before any key is retrieved. +//! Record envelope names do not add cryptographic map keys. +//! +//! Generic targets use [`CallerContext`] (ciphertext and terms) or [`AeadContext`] +//! (ciphertext only; a derived record made only of ciphertexts declares it with +//! `#[stash(context_type = AeadContext)]`). These own Vitamin C's context +//! encodings, preserving their structured descriptor identity. They are +//! constructed from a nonempty context, including a borrowed one — an +//! `AeadContext` from one with the AEAD encoding alone. Records that supply +//! their own field contexts use [`DeclaredContext`], whose default leaves those +//! contexts unchanged and whose nonempty form extends them. Custom targets may +//! instead declare `Context = ()`. +//! +//! Within one target, every operation runs under the one context the target is +//! handed (ADR-0004): the context is a type parameter of [`Encryption`], zipped +//! subtrees must need the same one and receive the same value, and a ciphertext +//! beside a term takes the term's `CallerContext` — of which its own +//! `AeadContext` is the AEAD half — through [`Encryption::accepting`]. A record +//! gives a field a context of its own with [`Encryption::under`] or +//! [`Encryption::extend`], and the caller's context then extends it. +//! +//! # Output adapters +//! +//! An adapter selects a core operation and converts only its completed output. +//! A [`transcode::Reader`] moves native ciphertext, terms, metadata, and sealed +//! structural markers into a target visitor. It creates no additional generic +//! tree or serialization buffer. The cipher's native tree, pending requests, and +//! the final target's own storage remain normal allocations. +//! +//! ``` +//! use stack_encrypt::{EncryptFrom, Encryption}; +//! use stack_encrypt::target::{self, CallerContext}; +//! use stack_encrypt::sem::EqualityTerm; +//! +//! struct StoredEquality([u8; 32]); +//! impl<S> EncryptFrom<S> for StoredEquality +//! where EqualityTerm: EncryptFrom<S, Context = CallerContext> { +//! type Context = CallerContext; +//! fn encryption<'s,K:'static>()->Encryption<'s,S,Self,K,Self::Context> +//! where S:'s { +//! <EqualityTerm as EncryptFrom<S>>::encryption().map(|term| Self(term.into_bytes())) +//! } +//! } +//! ``` +//! +//! Ciphertext operations require Vitamin C's `Encrypt`/`Decrypt` on the plaintext; +//! there is no Serde fallback. Terms require only their PRF or ordering capability. +//! New cryptographic operations belong in core; output adapters cannot install an +//! execution callback. The separate cipher-directed API remains public. +//! +//! # Collections and authentication +//! +//! `Vec<Target>` describes independently encrypted rows under the same context. +//! Encrypting a plaintext `Vec<T>` into `StackCipherText` instead follows Vitamin +//! C's native sequence model, including its authenticated empty marker. The same +//! distinction applies to `Option<Target>` versus a native encrypted option. +//! Readers preserve marker ciphertext and map keys; changing a key changes the +//! authenticated context when opening. Passthrough metadata and query terms are +//! not presented as AEAD-authenticated values. +//! +//! # Migration from the unpublished execution traits +//! +//! Implement `encryption`/`decryption`, returning core-owned descriptions, in place +//! of `encrypt_from`/`decrypt_into` methods that took a cipher. The derives do this +//! automatically. Call `keyset.encrypt_as(&value, context)` and +//! `cipher.decrypt_as(record, context)`, or import the blanket [`EncryptInto`] and +//! [`DecryptFrom`] convenience traits for the existing source-side call syntax. +//! `EncryptTarget` and `DecryptTarget` are no longer extension points. +mod context; +pub(crate) mod core; +mod operations; +mod pending; +mod request; +pub mod transcode; + +pub(crate) use self::core::{decipher_pending, seal_pending}; +pub use context::{AeadContext, CallerContext, DeclaredContext, ExpectedContext, Extends}; +pub use operations::{ + ciphertext, equality, matching, ope, open, ore, DecryptField, DecryptFrom, DecryptInto, + Decryptable, Decryption, EncryptFrom, EncryptInto, Encryption, +}; +pub use pending::{CipherScope, Pending, PendingFuture}; +pub use request::{Request, Responses}; +pub use stack_encrypt_derive::{DecryptInto, EncryptFrom}; diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs new file mode 100644 index 000000000..1e8d73746 --- /dev/null +++ b/packages/stack-encrypt/src/target/operations.rs @@ -0,0 +1,832 @@ +//! Core-owned operation descriptions: what a target declares, and the cipher +//! executes. No constructor here accepts a plaintext-and-cipher callback; a +//! description selects core operations and converts their completed output, +//! nothing more. +//! +//! No constructor takes a context either. The context reaches every operation +//! by being threaded through the tree that composes them, as a type parameter +//! of [`Encryption`] (ADR-0004): a target cannot route the context it is +//! handed to one operation and something else to another, because there is +//! no argument to route. What a subtree may do is take a context of its own, +//! by name, with [`Encryption::under`] or [`Encryption::extend`]. +use super::context::{AeadContext, CallerContext, DeclaredContext, Extends}; +use super::core::{encrypt_native, open_native, Term}; +use super::{CipherScope, Pending}; +use crate::{Error, KeysetCipher, NonEmpty, StackCipher, StackCipherText}; +use stack_kms::MaybeSend; +use std::fmt; + +/// Declaration that an encrypted target is produced from `S`. +/// +/// The derive supplies it; a hand-written implementation composes the +/// constructors in this module ([`ciphertext`], [`equality`], [`matching`], +/// [`ore`], [`ope`]) and converts their output with [`Encryption::map`] or +/// [`Encryption::transcode`]. Nothing here receives the plaintext or a +/// cipher: the returned [`Encryption`]'s execution is private, so a target +/// can choose operations and build its output but cannot replace encryption. +pub trait EncryptFrom<S>: Sized + 'static { + /// The context this target still needs when it is run — what a caller + /// supplies alongside the plaintext: a [`CallerContext`] for a target + /// that derives terms, an [`AeadContext`] for one that only seals, a + /// `NonEmpty<T>` for a record that stores its identifier, or a + /// [`DeclaredContext`] — which `()` satisfies — for a record whose fields + /// name their own. + type Context; + /// The description the cipher executes for one value of `S`. The context + /// is supplied when the description is run, not here, so every operation + /// beneath it receives the same one (ADR-0004). + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + where + S: 's; +} +/// Declaration of how a stored target recovers `P`. +/// +/// Inspection sees no cipher: the implementation selects the recoverable +/// ciphertext and its context, and [`open`] describes the rest. A query-only +/// target (terms alone) has no implementation. +pub trait DecryptInto<P>: Sized { + /// What a caller supplies to open the target. For a record that stores + /// its context this is an [`ExpectedContext`](super::ExpectedContext), + /// which may name the destination the caller believes it is opening. + type Context; + /// The description the cipher executes to recover `P`. + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<P, K>; +} + +#[cfg(not(target_arch = "wasm32"))] +type Build<'s, S, T, K, Ctx> = + Box<dyn for<'a, 'k> FnOnce(&S, &'a KeysetCipher<'k, K>, Ctx) -> Pending<'a, T, K> + Send + 's>; +#[cfg(target_arch = "wasm32")] +type Build<'s, S, T, K, Ctx> = + Box<dyn for<'a, 'k> FnOnce(&S, &'a KeysetCipher<'k, K>, Ctx) -> Pending<'a, T, K> + 's>; +#[cfg(not(target_arch = "wasm32"))] +type Open<T, K> = Box<dyn for<'a> FnOnce(&'a StackCipher<K>) -> Pending<'a, T, K> + Send>; +#[cfg(target_arch = "wasm32")] +type Open<T, K> = Box<dyn for<'a> FnOnce(&'a StackCipher<K>) -> Pending<'a, T, K>>; + +/// A composable description of how `T` is encrypted from `S`, under the +/// `Ctx` it is handed when it runs. +/// +/// Built from the constructors in this module and the combinators below; +/// executed only by [`KeysetCipher::encrypt_as`]. Nothing runs, and no key is +/// requested, until then. +/// +/// `Ctx` is the context this description still needs. It is a type parameter, +/// not a stored value, and that is what makes two rules hold at compile time +/// rather than by discipline (ADR-0004): +/// +/// - **One context per target.** [`zip`](Self::zip) requires both sides to +/// need the same `Ctx` and hands them the same value, so a target has no +/// way to route what it is handed to one side and something else to the +/// other. A ciphertext beside a term takes the term's context through +/// [`accepting`](Self::accepting): the [`AeadContext`] it seals under is +/// the AEAD half of that one value. A side given a context of its own, +/// with [`under`](Self::under) or [`extend`](Self::extend), says so in the +/// declaration; that is how a record names its fields' contexts, and the +/// tree does not tell a record's fields from a target's halves. +/// - **A leaf still cannot be reached without a context.** An operation needs +/// a real one. [`under`](Self::under) and [`extend`](Self::extend) are the +/// only ways to change the context a subtree runs under, and only `under` +/// discharges the requirement into a [`DeclaredContext`], which is what +/// `()` may satisfy. +#[must_use = "an encryption description does nothing until a keyset cipher executes it"] +pub struct Encryption<'s, S, T, K, Ctx> { + build: Build<'s, S, T, K, Ctx>, +} +/// A composable description of how `T` is recovered from a stored target. +/// +/// Built from [`open`] and the combinators below; executed only by +/// `decrypt_as`. Nothing runs, and no key is retrieved, until then. +#[must_use = "a decryption description does nothing until a cipher executes it"] +pub struct Decryption<T, K> { + inner: Opening<T, K>, +} +/// A declaration either failed while it was being built, or has an opening +/// to execute. A failure is held as a value rather than a closure that +/// yields it, so a combinator can see it without executing anything: that +/// is what lets [`Decryption::all`] stop at the first failed item. +enum Opening<T, K> { + Failed(Error), + Open(Open<T, K>), +} +impl<S, T, K, Ctx> fmt::Debug for Encryption<'_, S, T, K, Ctx> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Encryption").finish_non_exhaustive() + } +} +impl<T, K> fmt::Debug for Decryption<T, K> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("Decryption"); + if let Opening::Failed(error) = &self.inner { + let _ = debug.field("failed", error); + } + debug.finish_non_exhaustive() + } +} + +impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { + /// A description whose output is already known — metadata a record + /// carries, or a declaration rejected before any key request. + pub fn ready(result: Result<T, Error>) -> Self + where + T: MaybeSend, + { + Self { + build: Box::new(move |_, cipher, _| Pending::ready(cipher, result)), + } + } + /// Reject the declaration: execution yields `error` without I/O, and any + /// description this is zipped into fails with it. + pub fn failed(error: Error) -> Self { + Self { + build: Box::new(move |_, cipher, _| Pending::failed(cipher, error)), + } + } + /// Build the destination from the completed output. `f` sees ciphertext + /// and terms, never the plaintext. + pub fn map<U: 'static, F>(self, f: F) -> Encryption<'s, S, U, K, Ctx> + where + F: FnOnce(T) -> U + MaybeSend + 'static, + { + Encryption { + build: Box::new(move |source, cipher, cx| (self.build)(source, cipher, cx).map(f)), + } + } + /// [`map`](Self::map) for a conversion that can fail, such as reading + /// native output into a destination that does not accept every shape. + pub fn try_map<U: 'static, F>(self, f: F) -> Encryption<'s, S, U, K, Ctx> + where + F: FnOnce(T) -> Result<U, Error> + MaybeSend + 'static, + { + Encryption { + build: Box::new(move |source, cipher, cx| (self.build)(source, cipher, cx).try_map(f)), + } + } + /// Drive the destination's [`Visitor`](super::transcode::Visitor) from + /// this operation's native output, moving leaves and markers across + /// without an intermediate tree. + pub fn transcode<U: super::transcode::Transcode + 'static>(self) -> Encryption<'s, S, U, K, Ctx> + where + T: super::transcode::Reader, + { + self.try_map(|output| super::transcode::Reader::read(output, U::visitor())) + } + /// Run both descriptions over the same source, under the one context this + /// description is handed, settling their key requests in one batch. + /// + /// Both sides must need the same `Ctx`, and both receive the same value: + /// there is no second context to pass. A side may still have taken a + /// context of its own with [`under`](Self::under) or + /// [`extend`](Self::extend) before it got here — that is how a record + /// composes fields with different contexts — and `zip` cannot tell that + /// from a target's two halves (ADR-0004, decision 1). + pub fn zip<U: 'static>( + self, + other: Encryption<'s, S, U, K, Ctx>, + ) -> Encryption<'s, S, (T, U), K, Ctx> + where + Ctx: Clone, + { + Encryption { + build: Box::new(move |source, cipher, cx| { + (self.build)(source, cipher, cx.clone()).zip((other.build)(source, cipher, cx)) + }), + } + } + /// Take a different context type, converting on the way in. + /// + /// A ciphertext seals under an [`AeadContext`] while the term beside it + /// derives under a [`CallerContext`]: `accepting` lets the ciphertext + /// take the term's context, of which its own is the AEAD half, so the two + /// zip under one value. Likewise a record declares the context its + /// *caller* supplies, which need not be the type its operations need — a + /// record storing its own context declares `NonEmpty<T>` while its + /// operations want a `CallerContext` — and this adapts the one to the + /// other once, at the root. + pub fn accepting<C2>(self) -> Encryption<'s, S, T, K, C2> + where + C2: Into<Ctx> + 's, + { + self.needing(Into::into) + } + /// Need a different context, derived from the one supplied by `derive` + /// at the root of this subtree. The one place a context changes on its + /// way down; every public way of doing so is a closure handed here. + fn needing<C2, F>(self, derive: F) -> Encryption<'s, S, T, K, C2> + where + C2: 's, + F: FnOnce(C2) -> Ctx + MaybeSend + 's, + { + Encryption { + build: Box::new(move |source, cipher, cx| (self.build)(source, cipher, derive(cx))), + } + } + /// Run this whole subtree under `own`, extended by the surrounding + /// context if there is one. + /// + /// A record names each field once here rather than handing a context to + /// every operation separately. It is also what discharges the context an + /// operation needs, which is why a leaf that is never given a context of + /// its own cannot be run under `()`. Available wherever a + /// [`CallerContext`] can become what the subtree needs: a leaf of either + /// kind, or a record whose own contexts a caller's extends. + pub fn under(self, own: NonEmpty<&'static str>) -> Encryption<'s, S, T, K, DeclaredContext> + where + Ctx: From<CallerContext>, + { + self.needing(move |cx: DeclaredContext| cx.under(own).into()) + } + /// Run this whole subtree under `own`, extended by the surrounding + /// context `C` — which is still required. + /// + /// The sibling of [`under`](Self::under), for a record that cannot make + /// the caller's context optional because some *other* field of it is a + /// bare leaf. `C` is the caller's context type — a [`CallerContext`], or + /// an [`AeadContext`] for a record that only seals — and the same one + /// context reaches every operation beneath; the difference from `under` + /// is only whether `()` can satisfy the result. + pub fn extend<C>(self, own: NonEmpty<&'static str>) -> Encryption<'s, S, T, K, C> + where + C: Extends + 's, + Ctx: From<C>, + { + self.needing(move |cx: C| cx.extend(own).into()) + } + /// Lift a description of a field to a description of the struct that + /// holds it, which is how a `struct = T` derive composes its fields. + /// + /// The selector is a plain function pointer over a borrow: it captures + /// nothing, so it cannot reach a cipher, and what it returns is encrypted + /// under `S`'s own Vitamin C contract. It is a place to pick a field, not + /// to re-encode one. + pub fn project<P: 's>( + self, + select: for<'borrow> fn(&'borrow P) -> &'borrow S, + ) -> Encryption<'s, P, T, K, Ctx> { + Encryption { + build: Box::new(move |source, cipher, cx| (self.build)(select(source), cipher, cx)), + } + } +} + +impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's + Clone + MaybeSend + 'static> + Encryption<'s, S, T, K, Ctx> +{ + /// Build the output from the completed operations *and* the context they + /// ran under. + /// + /// For a record that stores its own context in a field + /// (`#[stash(context_field)]`): the context is supplied when the + /// description runs, so the field it populates is filled there too. + pub fn map_with_context<U: 'static, F>(self, f: F) -> Encryption<'s, S, U, K, Ctx> + where + F: FnOnce(T, Ctx) -> U + MaybeSend + 'static, + { + Encryption { + build: Box::new(move |source, cipher, cx: Ctx| { + let carried = cx.clone(); + (self.build)(source, cipher, cx).map(move |value| f(value, carried)) + }), + } + } +} + +/// The canonical ciphertext operation: seal `S`, under the [`AeadContext`] +/// the tree hands it, through its own Vitamin C `Encrypt` implementation, +/// into the native [`StackCipherText`] tree. There is no Serde fallback; a +/// plaintext without `Encrypt` does not compile. +/// +/// Sealing needs only the AEAD encoding of a context, so this needs an +/// `AeadContext` where a term needs a [`CallerContext`]. Beside a term, +/// [`accepting`](Encryption::accepting) lets it take the term's context — +/// the AEAD half of the same value — so the two zip under one context. +pub fn ciphertext<'s, S: crate::Encrypt + Clone + 's, K: 'static>( +) -> Encryption<'s, S, StackCipherText, K, AeadContext> { + Encryption { + build: Box::new( + move |source, cipher, cx: AeadContext| match cx.validated() { + Ok(ctx) => encrypt_native(source, cipher, ctx), + Err(e) => Pending::failed(cipher, e), + }, + ), + } +} +/// A term operation: `$function` produces `$output` from any `S` satisfying +/// the bounds, under the [`CallerContext`] the tree hands it. +macro_rules! term_operation { + ( + $(#[$doc:meta])* + $function:ident, $output:ty, [$($generics:tt)*], [$($bounds:tt)*] + ) => { + $(#[$doc])* + pub fn $function<'s, S, K: 'static, $($generics)*>( + ) -> Encryption<'s, S, $output, K, CallerContext> + where + S: 's, + $($bounds)* + { + Encryption { + build: Box::new(move |source, cipher, cx: CallerContext| match cx.validated() { + Ok(ctx) => <$output as Term<S, K, _>>::encrypt_from(source, cipher, ctx), + Err(e) => Pending::failed(cipher, e), + }), + } + } + }; +} +term_operation!( + /// The equality term of `S` under the context the tree hands it. Requires + /// only `S`'s PRF contract, not recoverable encryption. + equality, crate::sem::EqualityTerm, [], [S: vitaminc_prf::PrfValue + Clone] +); +term_operation!( + /// The match term of any text `S` under the context the tree hands it, + /// tokenised and hashed as `O` declares. + matching, crate::sem::MatchTerm<O>, [O: crate::sem::MatchConfig + 'static], [S: AsRef<str>] +); +term_operation!( + /// The order-revealing term of `S` under the context the tree hands it. + /// The bounds are the leaf's own: they say which `S` the CLLW ORE scheme + /// can order. + ore, crate::sem::OreTerm<S>, [], + [S: cllw_ore::CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static] +); +term_operation!( + /// The order-preserving term of `S` under the context the tree hands it, + /// with the same bounds as [`ore`]. + ope, crate::sem::OpeTerm<S>, [], + [S: cllw_ore::CllwOpeEncrypt + Clone + Send + 'static, S::Output: Send + 'static] +); + +impl<T: 'static, K: 'static> Decryption<T, K> { + /// Reject the opening: execution yields `error` without I/O, any + /// description this is zipped into fails with it, and a collection + /// ([`all`](Self::all)) stops at it. The derives use it when a stored + /// context fails validation. + pub fn failed(error: Error) -> Self { + Self { + inner: Opening::Failed(error), + } + } + fn open(open: Open<T, K>) -> Self { + Self { + inner: Opening::Open(open), + } + } + /// A description whose output is already known: a defaulted field, or + /// an absent optional. + pub fn ready(value: T) -> Self + where + T: MaybeSend, + { + Self::open(Box::new(move |cipher| Pending::ready(cipher, Ok(value)))) + } + /// Convert the recovered value. + pub fn map<U: 'static, F>(self, f: F) -> Decryption<U, K> + where + F: FnOnce(T) -> U + MaybeSend + 'static, + { + match self.inner { + Opening::Failed(error) => Decryption::failed(error), + Opening::Open(open) => Decryption::open(Box::new(move |cipher| open(cipher).map(f))), + } + } + /// Open both, retrieving their keys in one batch. A failed side fails + /// the pair, the left one first, without executing the other. + pub fn zip<U: 'static>(self, other: Decryption<U, K>) -> Decryption<(T, U), K> { + match (self.inner, other.inner) { + (Opening::Failed(error), _) | (_, Opening::Failed(error)) => Decryption::failed(error), + (Opening::Open(left), Opening::Open(right)) => { + Decryption::open(Box::new(move |cipher| left(cipher).zip(right(cipher)))) + } + } + } + /// Open every description, retrieving all their keys in one batch, and + /// collect the results in order. + /// + /// `items` is consumed only as far as its first failed description: the + /// column fails with that error, and the descriptions after it are never + /// built. A `Vec<T>` whose rows validate a stored context does not go on + /// validating rows once one has been refused. + pub fn all<I>(items: I) -> Decryption<Vec<T>, K> + where + I: IntoIterator<Item = Self>, + { + let mut opens = Vec::new(); + for item in items { + match item.inner { + Opening::Failed(error) => return Decryption::failed(error), + Opening::Open(open) => opens.push(open), + } + } + Decryption::open(Box::new(move |cipher| { + Pending::collect(cipher, opens.into_iter().map(|open| open(cipher))) + })) + } + /// An absent item recovers as `None` without I/O. + fn optional(item: Option<Self>) -> Decryption<Option<T>, K> + where + T: MaybeSend, + { + match item { + Some(item) => item.map(Some), + None => Decryption::ready(None), + } + } + /// Execute under a scope. A [`KeysetCipher`] scope refuses a leaf from any + /// other keyset; a [`StackCipher`] scope opens leaves from any. + fn open_in<'a>(self, scope: impl CipherScope<'a, K>) -> Pending<'a, T, K> { + let pending = match self.inner { + Opening::Failed(error) => return Pending::failed(scope, error), + Opening::Open(open) => open(scope.cipher()), + }; + match scope.keyset() { + Some(id) => pending.scoped_to(id), + None => pending, + } + } +} +/// The canonical opening operation: retrieve the tree's keys and decode `P` +/// through its own Vitamin C `Decrypt` implementation, under `context`. +pub fn open<P: crate::Decrypt<'static> + 'static, K: 'static>( + tree: StackCipherText, + context: impl Into<AeadContext>, +) -> Decryption<P, K> { + let context = context.into(); + Decryption::open(Box::new(move |cipher| match context.validated() { + Ok(ctx) => open_native(tree, cipher, ctx), + Err(e) => Pending::failed(cipher, e), + })) +} + +impl<K: 'static> KeysetCipher<'_, K> { + /// Encrypt `source` into `T` under this keyset, as `T`'s declaration + /// describes. The returned [`Pending`] settles every key request the + /// declaration made in one batch. + pub fn encrypt_as<'a, S, T>(&'a self, source: &S, context: T::Context) -> Pending<'a, T, K> + where + T: EncryptFrom<S>, + { + (T::encryption().build)(source, self, context) + } + /// Recover `P` from `source`, as its declaration describes. A leaf sealed + /// under another keyset is refused ([`Error::ForeignKeyset`]) before any + /// key is retrieved. + pub fn decrypt_as<'a, P: 'static, T>( + &'a self, + source: T, + context: T::Context, + ) -> Pending<'a, P, K> + where + T: DecryptInto<P>, + { + source.decryption(context).open_in(self) + } +} +impl<K: 'static> StackCipher<K> { + /// Recover `P` from `source`, as its declaration describes. Leaves from + /// any of the client's keysets open here. + pub fn decrypt_as<'a, P: 'static, T>( + &'a self, + source: T, + context: T::Context, + ) -> Pending<'a, P, K> + where + T: DecryptInto<P>, + { + source.decryption(context).open_in(self) + } +} + +/// Source-side call syntax for [`KeysetCipher::encrypt_as`], implemented for +/// every type: `value.encrypt_into(&keyset)`. Targets implement +/// [`EncryptFrom`], never these methods. +pub trait EncryptInto: Sized { + /// Encrypt into `T` with its default context — `()` for a declaration + /// that carries its own contexts. + fn encrypt_into<'a, T, K: 'static>(&self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> + where + T: EncryptFrom<Self>, + T::Context: Default, + { + cipher.encrypt_as(self, Default::default()) + } + /// Encrypt into `T` under `context`, accepting anything that converts + /// into `T`'s context — a `nonempty!` literal, say. + fn encrypt_into_with_context<'a, T, K: 'static>( + &self, + cipher: &'a KeysetCipher<'_, K>, + context: impl Into<T::Context>, + ) -> Pending<'a, T, K> + where + T: EncryptFrom<Self>, + { + cipher.encrypt_as(self, context.into()) + } + /// [`encrypt_into_with_context`](Self::encrypt_into_with_context) named + /// from the target's side: `Target::encrypt_from(&value, &keyset, ctx)`. + fn encrypt_from<'a, S, K: 'static>( + source: &S, + cipher: &'a KeysetCipher<'_, K>, + context: impl Into<<Self as EncryptFrom<S>>::Context>, + ) -> Pending<'a, Self, K> + where + Self: EncryptFrom<S>, + { + cipher.encrypt_as(source, context.into()) + } +} +impl<T> EncryptInto for T {} +/// Source-side call syntax for `decrypt_as`, implemented for every type: +/// `stored.decrypt_into(&cipher, ctx)`. Targets implement [`DecryptInto`], +/// never these methods. +pub trait DecryptFrom: Sized + 'static { + /// Recover `P` through `cipher`, which may be a [`KeysetCipher`] (refusing + /// foreign leaves) or a [`StackCipher`] (opening any). + fn decrypt_into<'a, P: 'static, K: 'static>( + self, + cipher: impl CipherScope<'a, K>, + context: impl Into<<Self as DecryptInto<P>>::Context>, + ) -> Pending<'a, P, K> + where + Self: DecryptInto<P>, + { + self.decryption(context.into()).open_in(cipher) + } + /// Recover `Self` from `source` with its default context — the plain + /// "read the stored context and validate it" for a record that stores + /// one. + fn decrypt_from<'a, S, K: 'static>( + source: S, + cipher: impl CipherScope<'a, K>, + ) -> Pending<'a, Self, K> + where + S: DecryptInto<Self> + 'static, + S::Context: Default, + { + source.decrypt_into(cipher, S::Context::default()) + } + /// Recover `Self` from `source` under `context`. + fn decrypt_from_with_context<'a, S, K: 'static>( + source: S, + cipher: impl CipherScope<'a, K>, + context: impl Into<S::Context>, + ) -> Pending<'a, Self, K> + where + S: DecryptInto<Self> + 'static, + { + source.decrypt_into(cipher, context.into()) + } +} +impl<T: 'static> DecryptFrom for T {} + +impl<S: crate::Encrypt + Clone> EncryptFrom<S> for StackCipherText { + type Context = AeadContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + where + S: 's, + { + ciphertext() + } +} +impl<P: crate::Decrypt<'static> + 'static> DecryptInto<P> for StackCipherText { + type Context = AeadContext; + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<P, K> { + open(self, context) + } +} +impl<S: vitaminc_prf::PrfValue + Clone> EncryptFrom<S> for crate::sem::EqualityTerm { + type Context = CallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + where + S: 's, + { + equality() + } +} +impl<S: AsRef<str>, O: crate::sem::MatchConfig + 'static> EncryptFrom<S> + for crate::sem::MatchTerm<O> +{ + type Context = CallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + where + S: 's, + { + matching() + } +} +impl<S> EncryptFrom<S> for crate::sem::OreTerm<S> +where + S: cllw_ore::CllwOreEncrypt + Clone + Send + 'static, + S::Output: Send + 'static, +{ + type Context = CallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + where + S: 's, + { + ore() + } +} +impl<S> EncryptFrom<S> for crate::sem::OpeTerm<S> +where + S: cllw_ore::CllwOpeEncrypt + Clone + Send + 'static, + S::Output: Send + 'static, +{ + type Context = CallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + where + S: 's, + { + ope() + } +} + +// A `Vec<Target>` is a row per item, each encrypted independently under the +// same context and settled in one batch; an `Option<Target>` is one row or +// nothing, without I/O. (A plaintext `Vec`/`Option` sealed into a +// `StackCipherText` is a different thing: Vitamin C's native sequence and +// option, with their authenticated markers.) +impl<S, T: EncryptFrom<S>> EncryptFrom<Vec<S>> for Vec<T> +where + T::Context: Clone + 'static + MaybeSend, +{ + type Context = T::Context; + fn encryption<'s, K: 'static>() -> Encryption<'s, Vec<S>, Self, K, Self::Context> + where + S: 's, + { + Encryption { + build: Box::new(move |source, cipher, cx: T::Context| { + Pending::collect( + cipher, + source + .iter() + .map(|item| cipher.encrypt_as(item, cx.clone())), + ) + }), + } + } +} +impl<S, T: EncryptFrom<S> + MaybeSend> EncryptFrom<Option<S>> for Option<T> +where + T::Context: 'static + MaybeSend, +{ + type Context = T::Context; + fn encryption<'s, K: 'static>() -> Encryption<'s, Option<S>, Self, K, Self::Context> + where + S: 's, + { + Encryption { + build: Box::new(move |source, cipher, cx: T::Context| match source { + Some(item) => cipher.encrypt_as(item, cx).map(Some), + None => Pending::ready(cipher, Ok(None)), + }), + } + } +} +impl<P: 'static, T: DecryptInto<P> + 'static> DecryptInto<Vec<P>> for Vec<T> +where + T::Context: Clone + 'static, +{ + type Context = T::Context; + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<Vec<P>, K> { + Decryption::all( + self.into_iter() + .map(|item| item.decryption(context.clone())), + ) + } +} +impl<P: 'static + MaybeSend, T: DecryptInto<P> + 'static> DecryptInto<Option<P>> for Option<T> { + type Context = T::Context; + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<Option<P>, K> { + Decryption::optional(self.map(|item| item.decryption(context))) + } +} + +/// How a derive finds the one recoverable field of a record among its terms. +/// +/// A term returns `None`: it is one-way. A ciphertext field returns its +/// opening description. Neither sees a cipher. Implemented for every leaf +/// type and for `Vec`/`Option` of them; a hand-written leaf that wraps a +/// [`StackCipherText`] implements it alongside [`Decryptable`]. +pub trait DecryptField<P, Ctx>: Sized { + /// The opening description, if this field holds recoverable ciphertext. + fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<P, K>>; +} +impl<P: 'static, Ctx> DecryptField<P, Ctx> for StackCipherText +where + Self: DecryptInto<P>, + Ctx: Into<<Self as DecryptInto<P>>::Context>, +{ + fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<P, K>> { + Some(self.decryption(context.into())) + } +} +impl<P: 'static, T: 'static + Decryptable + DecryptField<P, Ctx>, Ctx: Clone + 'static> + DecryptField<Vec<P>, Ctx> for Vec<T> +{ + fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<Vec<P>, K>> { + if !T::DECRYPTABLE { + return None; + } + Some(Decryption::all(self.into_iter().map(|item| { + item.decryption_field(context.clone()) + .unwrap_or_else(|| Decryption::failed(Error::NotOpened)) + }))) + } +} +impl<P: 'static + MaybeSend, T: 'static + Decryptable + DecryptField<P, Ctx>, Ctx: 'static> + DecryptField<Option<P>, Ctx> for Option<T> +{ + fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<Option<P>, K>> { + if !T::DECRYPTABLE { + return None; + } + Some(Decryption::optional(self.map(|item| { + item.decryption_field(context) + .unwrap_or_else(|| Decryption::failed(Error::NotOpened)) + }))) + } +} + +/// Whether a field type holds recoverable ciphertext. The derives require +/// exactly one such field per plaintext value; a term is never one. +pub trait Decryptable { + /// `true` for ciphertext, `false` for a term. + const DECRYPTABLE: bool; +} +impl Decryptable for StackCipherText { + const DECRYPTABLE: bool = true; +} +impl<T: Decryptable> Decryptable for Vec<T> { + const DECRYPTABLE: bool = T::DECRYPTABLE; +} +impl<T: Decryptable> Decryptable for Option<T> { + const DECRYPTABLE: bool = T::DECRYPTABLE; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{nonempty, sem::EqualityTerm}; + use stack_kms::FakeDataKeySource; + + #[tokio::test] + async fn an_optional_ciphertext_field_recovers_present_and_absent_values() { + let cipher = StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .unwrap(); + let keyset = cipher.default_keyset(); + let context = || AeadContext::from(nonempty!("users/nickname")); + + for expected in [Some("secret nickname".to_string()), None] { + let sealed: Option<StackCipherText> = match &expected { + Some(value) => Some(keyset.encrypt_as(value, context()).await.unwrap()), + None => None, + }; + let opening: Decryption<Option<String>, FakeDataKeySource> = sealed + .decryption_field(context()) + .expect("an optional ciphertext is a recoverable field even when absent"); + assert_eq!( + opening.open_in(&keyset).await.unwrap(), + expected, + "opening should recover the value that was sealed, or its absence" + ); + } + } + + #[test] + fn an_optional_term_is_never_a_recoverable_field() { + for term in [Some(EqualityTerm::from_bytes([7; 32])), None] { + let opening: Option<Decryption<Option<String>, FakeDataKeySource>> = + term.decryption_field(CallerContext::from(nonempty!("users/nickname"))); + assert!(opening.is_none(), "a term cannot recover plaintext"); + } + } + + #[test] + fn operation_debug_describes_the_operation_without_its_captured_value() { + let encryption: Encryption<'_, (), _, (), ()> = Encryption::ready(Ok("secret metadata")); + assert_eq!( + format!("{encryption:?}"), + "Encryption { .. }", + "a ready encryption should not print its captured value" + ); + + let decryption = Decryption::<_, ()>::ready("secret plaintext"); + assert_eq!( + format!("{decryption:?}"), + "Decryption { .. }", + "a ready decryption should not print its plaintext" + ); + let failure = Decryption::<(), ()>::failed(Error::NotOpened); + assert_eq!( + format!("{failure:?}"), + "Decryption { failed: NotOpened, .. }", + "a failed decryption should name its error and nothing else" + ); + } +} diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs new file mode 100644 index 000000000..b96d6bf66 --- /dev/null +++ b/packages/stack-encrypt/src/target/pending.rs @@ -0,0 +1,1451 @@ +//! [`Pending`]: the request carrier [`StackCipher`] hands back from +//! [`EncryptFrom`](super::EncryptFrom) / [`DecryptInto`](super::DecryptInto), +//! and the single place the target layer talks to ZeroKMS. +//! +//! A `Pending` is built synchronously and settled once. Combining pendings +//! merges their [`Request`]s without doing any I/O; awaiting the combined +//! result issues the batched ZeroKMS calls ([`dispatch`]) — one +//! `generate_keys` for every generate, since a pending mints under one +//! keyset, and one `retrieve_keys` per keyset the leaves being opened were +//! sealed under — and then runs each fulfilment over exactly the +//! [`Responses`] its own requests asked for. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::future::{Future, IntoFuture}; +use std::pin::Pin; + +use stack_kms::{DataKey, DataKeySource, GenerateKeyPayload, Iv, MaybeSend, RetrieveKeyPayload}; +use uuid::Uuid; + +use super::request::{tally, Request, RequestKind, Responses}; +use crate::{Descriptor, Error, KeysetCipher, StackCipher}; + +/// The boxed fulfilment: consumes this pending's slice of the responses and +/// produces the output. The `Send` split mirrors [`stack_kms::MaybeSend`] — +/// the underlying ZeroKMS futures are not `Send` on wasm32. +#[cfg(not(target_arch = "wasm32"))] +type FulfilBox<'a, T> = Box<dyn FnOnce(&mut Responses) -> Result<T, Error> + Send + 'a>; +/// See the native definition above; identical minus the `Send` bound. +#[cfg(target_arch = "wasm32")] +type FulfilBox<'a, T> = Box<dyn FnOnce(&mut Responses) -> Result<T, Error> + 'a>; + +/// The boxed future a [`Pending`] settles through; `Send` split as above. +#[cfg(not(target_arch = "wasm32"))] +pub type PendingFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, Error>> + Send + 'a>>; +/// See the native definition above; identical minus the `Send` bound. +#[cfg(target_arch = "wasm32")] +pub type PendingFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, Error>> + 'a>>; + +/// A request carrier resolving to `T`, built by the cipher executing an +/// encryption or decryption declaration. +/// +/// **Not a future** until awaited. A `Pending` holds the ZeroKMS requests its +/// value needs plus the fulfilment that shapes the responses; combining +/// pendings ([`zip`](Self::zip), [`map`](Self::map), [`all`](Self::all)) +/// merges requests *without doing any I/O*, which is where batching comes +/// from: however many pendings are merged, awaiting the result issues **one** +/// batched ZeroKMS call per request kind and then runs every fulfilment over +/// the shared response set. +/// +/// Construct leaves with [`ready`](Self::ready) (value already derived, +/// nothing to request) or [`request`](Self::request) (value needs ZeroKMS +/// responses). +#[must_use = "a Pending does nothing until it is awaited or merged into one that is; \ + dropping it silently discards the value and any error that produced it"] +pub struct Pending<'a, T, K> { + /// The cipher the settled batch dispatches through. Which + /// [`StackCipher`] value it is does not constrain merging — see + /// [`zip`](Self::zip) — only which client issues the calls. + cipher: &'a StackCipher<K>, + /// The keyset this pending is scoped to: the one a [`KeysetCipher`] + /// built it through, or none when it was built through the + /// [`StackCipher`]. A scoped pending mints every data key under this + /// keyset and opens leaves from no other; an unscoped one mints nothing + /// ([`Error::NoKeyset`]) and opens leaves from any keyset, one retrieve + /// call per keyset. Merging two scopes is [`Error::KeysetMismatch`]. + keyset: Option<Uuid>, + requests: Vec<Request>, + /// Set when the value already failed during the synchronous build + /// (`ready(Err(..))`, a keyset mismatch, a failed sibling). A failed + /// pending carries no requests, and merging one into an assembly drops + /// the assembly's requests too, so settling it does no I/O: a record + /// with one misconfigured field never mints data keys it will throw away. + /// When set, `fulfil` is never called. + failed: Option<Error>, + fulfil: FulfilBox<'a, T>, +} + +/// What a [`Pending`] is built through: a [`StackCipher`] (no keyset +/// scope) or a [`KeysetCipher`] (scoped to its keyset). Implemented for +/// references to both, so the constructors take either. +/// +/// Sealed: the two scopes are the two shapes, and a scope is how the target +/// layer asks a cipher what it is bound to — not an extension point. An +/// outside implementation could name any keyset id without holding the +/// keyset, which would make [`Pending`]'s scope rules +/// ([`Error::NoKeyset`], [`Error::ForeignKeyset`]) say less than they do: +/// a scope's id is one the cipher loaded from ZeroKMS. Downstream code +/// uses the cipher to execute [`EncryptFrom`](super::EncryptFrom); it never +/// implements a scope of its own. +pub trait CipherScope<'a, K>: sealed::Sealed { + /// The client-scoped cipher the pending settles through. + fn cipher(&self) -> &'a StackCipher<K>; + /// The keyset the pending is scoped to, if any. + fn keyset(&self) -> Option<Uuid>; +} + +mod sealed { + pub trait Sealed {} + impl<K> Sealed for &crate::StackCipher<K> {} + impl<K> Sealed for &crate::KeysetCipher<'_, K> {} +} + +impl<'a, K> CipherScope<'a, K> for &'a StackCipher<K> { + fn cipher(&self) -> &'a StackCipher<K> { + self + } + + fn keyset(&self) -> Option<Uuid> { + None + } +} + +impl<'a, K> CipherScope<'a, K> for &'a KeysetCipher<'_, K> { + fn cipher(&self) -> &'a StackCipher<K> { + KeysetCipher::cipher(self) + } + + fn keyset(&self) -> Option<Uuid> { + Some(self.keyset_id()) + } +} + +impl<'a, T: 'a, K> Pending<'a, T, K> { + /// A pending with no requests: `result` was fully derived during the + /// synchronous build. Awaiting it does no I/O. + pub fn ready(scope: impl CipherScope<'a, K>, result: Result<T, Error>) -> Self + where + T: MaybeSend, + { + match result { + Ok(value) => Self { + cipher: scope.cipher(), + keyset: scope.keyset(), + requests: Vec::new(), + failed: None, + fulfil: Box::new(move |_| Ok(value)), + }, + Err(error) => Self::failed(scope, error), + } + } + + /// A pending that already failed. No requests, no `T` bound (nothing of + /// type `T` is ever produced), and any assembly it is merged into fails + /// without I/O — see the `failed` field. + /// + /// Public because a hand-written `EncryptFrom` / `DecryptInto` that + /// works with a cipher scope directly reaches for it when a contract is + /// broken — e.g. [`Error::NotOpened`] for a field whose type declared + /// [`DECRYPTABLE`](super::Decryptable::DECRYPTABLE) but was passed over. + /// Inside an operation description, [`Encryption::failed`] and + /// [`Decryption::failed`] are the same thing without the scope; the + /// derives' generated code uses those. + /// + /// [`Encryption::failed`]: super::Encryption::failed + /// [`Decryption::failed`]: super::Decryption::failed + pub fn failed(scope: impl CipherScope<'a, K>, error: Error) -> Self { + Self { + cipher: scope.cipher(), + keyset: scope.keyset(), + requests: Vec::new(), + failed: Some(error), + // Unreachable: `settle` returns the stored error before any + // fulfilment runs. Kept honest rather than panicking. + fulfil: Box::new(|_| { + Err(Error::Other( + "fulfilment invoked on an already-failed pending".into(), + )) + }), + } + } + + /// A pending whose value needs ZeroKMS responses. `fulfil` runs after the + /// batched call, scoped to exactly the responses `requests` asked for — + /// drawing more (or another kind) is [`Error::ResponseShape`], and can + /// never consume a sibling pending's responses. + /// + /// The scope is exact in both directions: a fulfilment must also consume + /// *every* response it asked for. Leaving one behind is + /// [`Error::ResponseShape`] too — the key was minted at ZeroKMS, and + /// silently discarding it means the pending's declared requests do not + /// describe what it actually does. + /// + /// The keyset rules apply at construction, before any I/O: a generate + /// request through a [`StackCipher`] scope is [`Error::NoKeyset`], and + /// a retrieve request naming another keyset than a [`KeysetCipher`] + /// scope's is [`Error::ForeignKeyset`]. + pub fn request<F>(scope: impl CipherScope<'a, K>, requests: Vec<Request>, fulfil: F) -> Self + where + F: FnOnce(&mut Responses) -> Result<T, Error> + MaybeSend + 'a, + { + let (generated, retrieved) = tally(&requests); + if let Err(error) = check_scope(scope.keyset(), &requests) { + return Self::failed(scope, error); + } + Self { + cipher: scope.cipher(), + keyset: scope.keyset(), + requests, + failed: None, + fulfil: Box::new(move |responses| { + let mut own = responses.split_front(generated, retrieved)?; + let value = fulfil(&mut own)?; + if !own.is_exhausted() { + return Err(Error::ResponseShape); + } + Ok(value) + }), + } + } + + /// Transform the resolved value. No I/O, no new requests. + pub fn map<U: 'a, F>(self, f: F) -> Pending<'a, U, K> + where + F: FnOnce(T) -> U + MaybeSend + 'a, + { + let fulfil = self.fulfil; + Pending { + cipher: self.cipher, + keyset: self.keyset, + requests: self.requests, + failed: self.failed, + fulfil: Box::new(move |responses| fulfil(responses).map(f)), + } + } + + /// Fallibly transform the resolved value without issuing another request. + pub fn try_map<U: 'a, F>(self, f: F) -> Pending<'a, U, K> + where + F: FnOnce(T) -> Result<U, Error> + MaybeSend + 'a, + { + let fulfil = self.fulfil; + Pending { + cipher: self.cipher, + keyset: self.keyset, + requests: self.requests, + failed: self.failed, + fulfil: Box::new(move |responses| fulfil(responses).and_then(f)), + } + } + + /// Scope this pending to `keyset`: what a [`KeysetCipher`]'s decrypt + /// does to the pending its [`StackCipher`] built, so that opening a + /// leaf from any other keyset fails before any key is retrieved. + /// Scoping a pending already scoped to another keyset is + /// [`Error::KeysetMismatch`]. + pub(crate) fn scoped_to(self, keyset: Uuid) -> Self { + if let Some(existing) = self.keyset { + if existing != keyset { + return Pending::failed( + self.cipher, + Error::KeysetMismatch { + left: existing, + right: keyset, + }, + ); + } + } + if let Err(error) = check_scope(Some(keyset), &self.requests) { + return Pending::failed(self.cipher, error); + } + Pending { + keyset: Some(keyset), + ..self + } + } + + /// Merge two pendings into one resolving to the pair. Their requests + /// concatenate — awaiting the result is still one batched call per + /// request kind. + /// + /// Both must agree on a keyset if both are scoped to one: the merged + /// assembly mints every key under one keyset, so merging one tenant's + /// pending with another's is [`Error::KeysetMismatch`], not a debug + /// assertion. A scoped pending merged with an unscoped one takes the + /// scope. If either side already failed, the result is that failure and + /// carries no requests. + /// + /// The keyset is the whole rule: the two sides need not have been built + /// through the *same* [`StackCipher`] value. The merged assembly + /// dispatches through one of them, and every request it carries is + /// keyset-addressed — a generate mints under the merged scope, a + /// retrieve names the keyset its leaf was sealed under. A keyset id is + /// global, and a cipher only holds a keyset ZeroKMS resolved for its + /// client, so either side's client can dispatch the batch; a client that + /// is *not* authorised for the keyset is refused at ZeroKMS + /// ([`Error::Kms`]), exactly as it would be on its own. + pub fn zip<U: 'a>(self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> { + let keyset = match merge_scopes(self.keyset, other.keyset) { + Ok(keyset) => keyset, + Err(error) => return Pending::failed(self.cipher, error), + }; + if let Some(error) = self.failed.or(other.failed) { + return Pending::failed(self.cipher, error); + } + let mut requests = self.requests; + requests.extend(other.requests); + if let Err(error) = check_scope(keyset, &requests) { + return Pending::failed(self.cipher, error); + } + let first = self.fulfil; + let second = other.fulfil; + Pending { + cipher: self.cipher, + keyset, + requests, + failed: None, + fulfil: Box::new(move |responses| Ok((first(responses)?, second(responses)?))), + } + } + + /// Merge any number of same-typed pendings into one resolving to the + /// `Vec` — [`zip`](Self::zip) at scale, used by the `Vec<T>` + /// implementations to make a whole column one batched call. Same rule as + /// `zip`: every item must agree with `scope`'s keyset — which cipher + /// value each was built through does not matter, for the reason `zip` + /// gives — and the first failed item fails the whole column with no I/O. + pub fn all( + scope: impl CipherScope<'a, K>, + items: Vec<Pending<'a, T, K>>, + ) -> Pending<'a, Vec<T>, K> { + Self::collect(scope, items) + } + + /// Build a collection lazily, stopping at the first local failure so a bad + /// context does not repeat work for every remaining row. + pub(crate) fn collect( + scope: impl CipherScope<'a, K>, + items: impl IntoIterator<Item = Pending<'a, T, K>>, + ) -> Pending<'a, Vec<T>, K> { + let items = items.into_iter(); + let cipher = scope.cipher(); + let mut keyset = scope.keyset(); + let mut requests = Vec::new(); + let mut fulfils = Vec::with_capacity(items.size_hint().0); + for item in items { + keyset = match merge_scopes(keyset, item.keyset) { + Ok(keyset) => keyset, + Err(error) => return Pending::failed(cipher, error), + }; + if let Some(error) = item.failed { + return Pending::failed(cipher, error); + } + requests.extend(item.requests); + fulfils.push(item.fulfil); + } + if let Err(error) = check_scope(keyset, &requests) { + return Pending::failed(cipher, error); + } + Pending { + cipher, + keyset, + requests, + failed: None, + fulfil: Box::new(move |responses| { + fulfils + .into_iter() + .map(|fulfil| fulfil(responses)) + .collect() + }), + } + } +} + +impl<'a, T: 'a, K> Pending<'a, T, K> +where + K: DataKeySource, +{ + /// Settle: one batched ZeroKMS call per request kind (none at all for an + /// all-[`ready`](Pending::ready) assembly), then the fulfilments shape the + /// responses. This is the only place I/O happens — the cipher-directed + /// API ([`KeysetCipher::encrypt`] / [`StackCipher::decrypt`]) settles + /// through here too, so there is exactly one path to ZeroKMS. + /// + /// Unboxed, so it carries no `Send`/`Sync` demands beyond the backend's + /// own; the public [`IntoFuture`] impl boxes it. + pub(crate) async fn settle(self) -> Result<T, Error> { + if let Some(error) = self.failed { + return Err(error); + } + let mut responses = dispatch(self.cipher, self.keyset, self.requests).await?; + (self.fulfil)(&mut responses) + } +} + +/// The keyset two merged pendings share: either's when the other has none, +/// [`Error::KeysetMismatch`] when both have one and they differ. +fn merge_scopes(left: Option<Uuid>, right: Option<Uuid>) -> Result<Option<Uuid>, Error> { + match (left, right) { + (Some(left), Some(right)) if left != right => Err(Error::KeysetMismatch { left, right }), + (Some(keyset), _) | (_, Some(keyset)) => Ok(Some(keyset)), + (None, None) => Ok(None), + } +} + +/// The keyset rules over a request list, applied wherever requests meet a +/// scope — construction, scoping, merging — so a violation fails the +/// pending before any I/O: a generate request needs a keyset to mint under +/// ([`Error::NoKeyset`]), and a retrieve request in a scoped pending must +/// name that keyset ([`Error::ForeignKeyset`]). +fn check_scope(keyset: Option<Uuid>, requests: &[Request]) -> Result<(), Error> { + for request in requests { + match (keyset, request.retrieve_keyset()) { + (None, None) => return Err(Error::NoKeyset), + (Some(expected), Some(found)) if expected != found => { + return Err(Error::ForeignKeyset { expected, found }); + } + _ => {} + } + } + Ok(()) +} + +/// Awaiting a `Pending` settles it. The boxed future is `Send` on native +/// targets (see [`PendingFuture`]), which is what requires `K: Sync` there: +/// the future holds `&StackCipher<K>`. On wasm32 the future is not `Send`, +/// so the `Sync` demand would only shut out the `Rc`/`RefCell`-shaped +/// sources that are natural on that target — it is dropped, mirroring the +/// [`MaybeSend`] split. +#[cfg(not(target_arch = "wasm32"))] +impl<'a, T: 'a, K> IntoFuture for Pending<'a, T, K> +where + K: DataKeySource + Sync, +{ + type Output = Result<T, Error>; + type IntoFuture = PendingFuture<'a, T>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(self.settle()) + } +} + +/// See the native impl above; identical minus the `Sync` bound. +#[cfg(target_arch = "wasm32")] +impl<'a, T: 'a, K> IntoFuture for Pending<'a, T, K> +where + K: DataKeySource, +{ + type Output = Result<T, Error>; + type IntoFuture = PendingFuture<'a, T>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(self.settle()) + } +} + +/// One [`RequestKind::RetrieveDataKey`], unpacked for +/// [`dispatch`]: the retrieves are grouped by `keyset_id` and read back by +/// index, so they are held as a list of their own rather than as requests. +struct Retrieve { + iv: Iv, + tag: Vec<u8>, + descriptor: Descriptor, + keyset_id: Uuid, +} + +/// Issue the batched ZeroKMS calls for `requests`: at most one +/// `generate_keys` (under the pending's keyset) and one `retrieve_keys` per +/// keyset the retrieved leaves were sealed under, whatever the request +/// count. When ZeroKMS grows a combined operation (data keys + PRF +/// derivations in one round-trip), this is the one place that changes. +async fn dispatch<K: DataKeySource>( + cipher: &StackCipher<K>, + keyset: Option<Uuid>, + requests: Vec<Request>, +) -> Result<Responses, Error> { + let mut generates: Vec<Descriptor> = Vec::new(); + let mut retrieves: Vec<Retrieve> = Vec::new(); + for request in requests { + match request.into_kind() { + RequestKind::GenerateDataKey { descriptor } => generates.push(descriptor), + RequestKind::RetrieveDataKey { + iv, + tag, + descriptor, + keyset_id, + } => retrieves.push(Retrieve { + iv, + tag, + descriptor, + keyset_id, + }), + } + } + + // ZeroKMS binds a descriptor into a fixed-size block and does not check + // the length itself. This is the gate: every request passes through + // here, including ones built directly from the `pub` constructors. The + // entry points check the root descriptor earlier as well, so a tree of + // ten thousand leaves is refused before ten thousand requests exist — + // a fast path, not a second rule. + generates + .iter() + .chain(retrieves.iter().map(|retrieve| &retrieve.descriptor)) + .try_for_each(Descriptor::check)?; + + let generated = if generates.is_empty() { + Vec::new() + } else { + // Every constructor checks this before any I/O (`check_scope`), so + // an unscoped generate cannot reach here; kept as the rule, not + // as an assumption. + let keyset = keyset.ok_or(Error::NoKeyset)?; + // Each leaf's descriptor is its context, rendered; the lock context + // stays empty — see the descriptor module docs. + let payloads: Vec<GenerateKeyPayload<'_>> = generates + .iter() + .map(|descriptor| GenerateKeyPayload::new(descriptor.as_str(), Cow::Owned(Vec::new()))) + .collect(); + let expected = payloads.len(); + let keys = cipher + .kms() + .generate_keys(payloads, Some(keyset), None) + .await?; + if keys.len() != expected { + return Err(Error::KeyCountMismatch { + expected, + received: keys.len(), + }); + } + keys + }; + + // Retrieves group by the keyset each leaf names — one call per keyset, + // in first-seen order — and the keys scatter back into request order, + // which is the order the fulfilments draw them in. + let mut groups: Vec<(Uuid, Vec<usize>)> = Vec::new(); + let mut group_of: HashMap<Uuid, usize> = HashMap::new(); + for (index, retrieve) in retrieves.iter().enumerate() { + let group = *group_of.entry(retrieve.keyset_id).or_insert_with(|| { + groups.push((retrieve.keyset_id, Vec::new())); + groups.len() - 1 + }); + groups[group].1.push(index); + } + let mut retrieved: Vec<Option<DataKey>> = std::iter::repeat_with(|| None) + .take(retrieves.len()) + .collect(); + for (keyset_id, indices) in groups { + let payloads: Vec<RetrieveKeyPayload<'_>> = indices + .iter() + .map(|&index| { + let retrieve = &retrieves[index]; + RetrieveKeyPayload::new(retrieve.iv, retrieve.descriptor.as_str(), &retrieve.tag) + }) + .collect(); + let expected = payloads.len(); + let keys = cipher + .kms() + .retrieve_keys(payloads, Some(keyset_id), None) + .await?; + if keys.len() != expected { + return Err(Error::KeyCountMismatch { + expected, + received: keys.len(), + }); + } + for (index, key) in indices.into_iter().zip(keys) { + retrieved[index] = Some(key); + } + } + // Every slot was filled by exactly one group; a hole would mean the + // grouping above lost a request, which is a bug here, not a data error. + let retrieved: Vec<DataKey> = retrieved + .into_iter() + .map(|key| key.ok_or(Error::ResponseShape)) + .collect::<Result<_, _>>()?; + + Ok(Responses::new(generated, retrieved)) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; + + use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKey, IndexKeySource, UnverifiedContext}; + use uuid::Uuid; + + use super::*; + + /// Counts ZeroKMS *calls* (not keys) so the batching claim — one call per + /// request kind however many pendings were merged — is testable. Delegates + /// everything else to the stub. + #[derive(Default)] + struct CountingSource { + inner: FakeDataKeySource, + generate_calls: AtomicUsize, + retrieve_calls: AtomicUsize, + /// The descriptors of every payload sent, per call, in payload order. + generate_descriptors: Mutex<Vec<Vec<String>>>, + retrieve_descriptors: Mutex<Vec<Vec<String>>>, + /// The keyset each call named, in call order. + generate_keysets: Mutex<Vec<Option<Uuid>>>, + retrieve_keysets: Mutex<Vec<Option<Uuid>>>, + } + + impl CountingSource { + fn generate_calls(&self) -> usize { + self.generate_calls.load(Ordering::Relaxed) + } + + fn retrieve_calls(&self) -> usize { + self.retrieve_calls.load(Ordering::Relaxed) + } + + fn generate_descriptors(&self) -> Vec<Vec<String>> { + self.generate_descriptors.lock().unwrap().clone() + } + + fn retrieve_descriptors(&self) -> Vec<Vec<String>> { + self.retrieve_descriptors.lock().unwrap().clone() + } + + fn generate_keysets(&self) -> Vec<Option<Uuid>> { + self.generate_keysets.lock().unwrap().clone() + } + + fn retrieve_keysets(&self) -> Vec<Option<Uuid>> { + self.retrieve_keysets.lock().unwrap().clone() + } + } + + impl DataKeySource for CountingSource { + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<stack_kms::DataKeyWithTag>, stack_kms::Error> { + self.generate_calls.fetch_add(1, Ordering::Relaxed); + self.generate_descriptors + .lock() + .unwrap() + .push(payloads.iter().map(|p| p.descriptor.to_owned()).collect()); + self.generate_keysets.lock().unwrap().push(keyset_id); + self.inner + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<stack_kms::DataKey>, stack_kms::Error> { + self.retrieve_calls.fetch_add(1, Ordering::Relaxed); + self.retrieve_descriptors + .lock() + .unwrap() + .push(payloads.iter().map(|p| p.descriptor.to_owned()).collect()); + self.retrieve_keysets.lock().unwrap().push(keyset_id); + self.inner + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } + } + + impl IndexKeySource for CountingSource { + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.inner.load_index_key(keyset_id).await + } + } + + fn d() -> Descriptor { + Descriptor::of("test/field") + } + + async fn cipher() -> StackCipher<CountingSource> { + StackCipher::builder() + .kms(CountingSource::default()) + .init() + .await + .unwrap() + } + + /// A pending that asks for `n` data keys and resolves to their tags. + fn generating<'a>( + keyset: &'a KeysetCipher<'_, CountingSource>, + n: usize, + ) -> Pending<'a, Vec<Vec<u8>>, CountingSource> { + let requests = std::iter::repeat_with(|| Request::generate_under(d())) + .take(n) + .collect(); + Pending::request(keyset, requests, move |responses| { + (0..n) + .map(|_| responses.next_generated_key().map(|key| key.tag)) + .collect() + }) + } + + #[tokio::test] + async fn a_ready_pending_resolves_without_any_io() { + let cipher = cipher().await; + let value: u32 = Pending::ready(&cipher, Ok(7)).await.unwrap(); + + assert_eq!(value, 7, "a ready pending resolves to the value it holds"); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "a ready pending must not generate any key" + ); + assert_eq!(cipher.kms().retrieve_calls(), 0, "nor retrieve one"); + } + + #[tokio::test] + async fn a_ready_pending_propagates_its_error() { + let cipher = cipher().await; + let result: Result<u32, Error> = Pending::ready(&cipher, Err(Error::Aead)).await; + + assert!( + matches!(result, Err(Error::Aead)), + "the error a ready pending was given comes back: {result:?}" + ); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "a failed pending does no I/O" + ); + } + + #[tokio::test] + async fn map_transforms_the_resolved_value() { + let cipher = cipher().await; + let value = Pending::ready(&cipher, Ok(7u32)) + .map(|v| v * 3) + .await + .unwrap(); + + assert_eq!(value, 21, "map runs over the resolved value"); + } + + #[tokio::test] + async fn map_does_not_run_on_an_error() { + let cipher = cipher().await; + let result: Result<u32, Error> = Pending::ready(&cipher, Err(Error::Aead)) + .map(|_: u32| panic!("map must not run on an error")) + .await; + + assert!( + matches!(result, Err(Error::Aead)), + "the error passes through untouched: {result:?}" + ); + } + + #[tokio::test] + async fn map_carries_the_requests_through() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let tags = generating(&keyset, 3).map(|tags| tags.len()).await.unwrap(); + + assert_eq!(tags, 3, "map sees all three keys the pending asked for"); + assert_eq!( + cipher.kms().generate_calls(), + 1, + "mapping does not split the batch" + ); + } + + #[tokio::test] + async fn one_pending_asking_for_many_keys_is_one_call() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let tags = generating(&keyset, 5).await.unwrap(); + + assert_eq!(tags.len(), 5, "every requested key comes back"); + assert_eq!( + cipher.kms().generate_calls(), + 1, + "five keys, one generate_keys call" + ); + } + + #[tokio::test] + async fn zip_merges_requests_into_one_call() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let (left, right) = generating(&keyset, 2) + .zip(generating(&keyset, 3)) + .await + .unwrap(); + + assert_eq!( + (left.len(), right.len()), + (2, 3), + "each side draws exactly its own keys" + ); + assert_eq!( + cipher.kms().generate_calls(), + 1, + "zipping merges the two request lists into one call" + ); + } + + /// The scoping guarantee at the `Pending` level: zipped fulfilments draw + /// disjoint response slices, in build order. + #[tokio::test] + async fn zipped_fulfilments_never_share_key_material() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let (left, right) = generating(&keyset, 2) + .zip(generating(&keyset, 2)) + .await + .unwrap(); + + for tag in &left { + assert!(!right.contains(tag), "a sibling drew the same key"); + } + } + + #[tokio::test] + async fn zip_of_two_ready_pendings_does_no_io() { + let cipher = cipher().await; + let pair = Pending::ready(&cipher, Ok(1u32)) + .zip(Pending::ready(&cipher, Ok("two"))) + .await + .unwrap(); + + assert_eq!(pair, (1, "two"), "both ready values resolve, in order"); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "nothing was requested, so nothing is dispatched" + ); + } + + #[tokio::test] + async fn zip_propagates_an_error_from_either_side() { + let cipher = cipher().await; + let result = Pending::ready(&cipher, Err(Error::Aead)) + .zip(Pending::ready(&cipher, Ok(1u32))) + .await; + assert!( + matches!(result, Err::<(u32, u32), _>(Error::Aead)), + "a failure on the left fails the pair: {result:?}" + ); + + let result = Pending::ready(&cipher, Ok(1u32)) + .zip(Pending::ready(&cipher, Err(Error::Aead))) + .await; + assert!( + matches!(result, Err::<(u32, u32), _>(Error::Aead)), + "and so does one on the right: {result:?}" + ); + } + + #[tokio::test] + async fn all_merges_a_column_into_one_call_preserving_order() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let items = (0..5).map(|_| generating(&keyset, 1)).collect(); + let column = Pending::all(&cipher, items).await.unwrap(); + + assert_eq!(column.len(), 5, "every item resolves, in build order"); + assert_eq!( + cipher.kms().generate_calls(), + 1, + "a whole column is one generate_keys call" + ); + + // Every row drew its own key. + let mut tags: Vec<&Vec<u8>> = column.iter().flatten().collect(); + tags.sort(); + tags.dedup(); + assert_eq!(tags.len(), 5, "no two rows drew the same key"); + } + + #[tokio::test] + async fn all_of_nothing_resolves_empty_without_io() { + let cipher = cipher().await; + let column: Vec<u32> = Pending::all(&cipher, Vec::new()).await.unwrap(); + + assert!(column.is_empty(), "an empty column resolves empty"); + assert_eq!(cipher.kms().generate_calls(), 0, "and dispatches nothing"); + } + + #[tokio::test] + async fn all_propagates_the_first_error() { + let cipher = cipher().await; + let items = vec![ + Pending::ready(&cipher, Ok(1u32)), + Pending::ready(&cipher, Err(Error::Aead)), + ]; + let result = Pending::all(&cipher, items).await; + + assert!( + matches!(result, Err::<Vec<u32>, _>(Error::Aead)), + "the first failed item fails the column: {result:?}" + ); + } + + #[tokio::test] + async fn a_lazy_column_stops_building_after_a_local_failure() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let built = AtomicUsize::new(0); + let items = (0..10_000).map(|index| { + built.fetch_add(1, Ordering::Relaxed); + if index == 1 { + Pending::failed(&keyset, Error::Aead) + } else { + generating(&keyset, 1) + } + }); + let result = Pending::collect(&keyset, items).await; + assert!(matches!(result, Err(Error::Aead))); + assert_eq!(built.load(Ordering::Relaxed), 2); + assert_eq!(cipher.kms().generate_calls(), 0); + } + + /// Over-drawing is the fulfilment's own error, not a stolen sibling key: + /// the second pending still resolves to the key it asked for. + #[tokio::test] + async fn over_drawing_responses_is_a_response_shape_error() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let greedy: Pending<'_, Vec<u8>, _> = + Pending::request(&keyset, vec![Request::generate_under(d())], |responses| { + let _ = responses.next_generated_key()?; + // One request, two draws. + responses.next_generated_key().map(|key| key.tag) + }); + let result = greedy.zip(generating(&keyset, 1)).await; + + assert!( + matches!( + result, + Err::<(Vec<u8>, Vec<Vec<u8>>), _>(Error::ResponseShape) + ), + "drawing past its own requests is the fulfilment's own error: {result:?}" + ); + } + + /// Under-drawing is an error for the same reason over-drawing is: the + /// pending's declared requests must describe what it actually consumes. + /// A key was minted at ZeroKMS; leaving it behind is a composition bug, + /// not a cheaper request. + /// + /// (That it does not *shift* a sibling's slice is a separate guarantee, + /// covered by `Responses::split_front`'s own tests.) + #[tokio::test] + async fn under_drawing_responses_is_a_response_shape_error() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let lazy: Pending<'_, (), _> = + Pending::request(&keyset, vec![Request::generate_under(d())], |_| Ok(())); + let result = lazy.zip(generating(&keyset, 1)).await; + + assert!( + matches!(result, Err::<((), Vec<Vec<u8>>), _>(Error::ResponseShape)), + "leaving a minted key unconsumed is a composition bug: {result:?}" + ); + } + + /// Partial consumption counts too: two requested, one drawn. + #[tokio::test] + async fn drawing_fewer_responses_than_requested_is_a_response_shape_error() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let requests = vec![Request::generate_under(d()), Request::generate_under(d())]; + let lazy: Pending<'_, Vec<u8>, _> = Pending::request(&keyset, requests, |responses| { + responses.next_generated_key().map(|key| key.tag) + }); + + let result = lazy.await; + assert!( + matches!(result, Err::<Vec<u8>, _>(Error::ResponseShape)), + "two requested, one drawn, is still an under-draw: {result:?}" + ); + } + + /// The two kinds are tracked separately: consuming every generated key + /// but none of the retrieved ones is still an under-draw. + #[tokio::test] + async fn leaving_the_other_kind_unconsumed_is_a_response_shape_error() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let mut pairs = generating_pairs(&keyset, 1).await.unwrap(); + let (iv, tag) = pairs.remove(0); + let requests = vec![ + Request::generate_under(d()), + Request::retrieve_under(iv, tag, d(), keyset.keyset_id()), + ]; + let lazy: Pending<'_, Vec<u8>, _> = Pending::request(&keyset, requests, |responses| { + responses.next_generated_key().map(|key| key.tag) + }); + + let result = lazy.await; + assert!( + matches!(result, Err::<Vec<u8>, _>(Error::ResponseShape)), + "the retrieved key was left behind: {result:?}" + ); + } + + #[tokio::test] + async fn a_pending_with_no_requests_dispatches_nothing() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let value: u32 = Pending::request(&keyset, Vec::new(), |_| Ok(9)) + .await + .unwrap(); + + assert_eq!(value, 9, "a request-free pending still resolves"); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "no requests, no generate_keys call" + ); + assert_eq!( + cipher.kms().retrieve_calls(), + 0, + "no requests, no retrieve_keys call" + ); + } + + /// A pending that asks for `n` data keys and resolves to the `(iv, tag)` + /// pairs needed to retrieve them again. + fn generating_pairs<'a>( + keyset: &'a KeysetCipher<'_, CountingSource>, + n: usize, + ) -> Pending<'a, Vec<(Iv, Vec<u8>)>, CountingSource> { + let requests = std::iter::repeat_with(|| Request::generate_under(d())) + .take(n) + .collect(); + Pending::request(keyset, requests, move |responses| { + (0..n) + .map(|_| { + responses + .next_generated_key() + .map(|key| (key.key.iv, key.tag)) + }) + .collect() + }) + } + + /// The two request kinds dispatch independently: mixing them in one + /// awaited assembly is one `generate_keys` *and* one `retrieve_keys`. + #[tokio::test] + async fn generate_and_retrieve_are_one_call_each() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let pairs = generating_pairs(&keyset, 2).await.unwrap(); + assert_eq!( + cipher.kms().generate_calls(), + 1, + "the setup seal is one call" + ); + + let requests: Vec<Request> = pairs + .iter() + .map(|(iv, tag)| Request::retrieve_under(*iv, tag.clone(), d(), keyset.keyset_id())) + .collect(); + let retrieve: Pending<'_, usize, _> = Pending::request(&keyset, requests, |responses| { + Ok(responses.drain_retrieved().count()) + }); + let (count, fresh) = retrieve.zip(generating(&keyset, 1)).await.unwrap(); + + assert_eq!(count, 2, "both keys were retrieved"); + assert_eq!(fresh.len(), 1, "and the fresh key was generated"); + assert_eq!( + cipher.kms().generate_calls(), + 2, + "one generate for the setup, one for the mixed assembly" + ); + assert_eq!( + cipher.kms().retrieve_calls(), + 1, + "the mixed assembly retrieves in one call" + ); + } + + /// Every request's descriptor reaches ZeroKMS on its own payload, in + /// request order, on both the generate and the retrieve call: the + /// descriptor is what binds the key to its field at ZeroKMS. + #[tokio::test] + async fn dispatch_forwards_each_requests_descriptor_in_order() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let requests = vec![ + Request::generate_under(Descriptor::of("users/email")), + Request::generate_under(Descriptor::of("users/name")), + ]; + let pairs: Vec<(Iv, Vec<u8>)> = Pending::request(&keyset, requests, |responses| { + (0..2) + .map(|_| { + responses + .next_generated_key() + .map(|key| (key.key.iv, key.tag)) + }) + .collect() + }) + .await + .unwrap(); + assert_eq!( + cipher.kms().generate_descriptors(), + vec![vec!["users/email".to_owned(), "users/name".to_owned()]] + ); + + let requests: Vec<Request> = pairs + .iter() + .zip(["users/name", "users/email"]) + .map(|((iv, tag), descriptor)| { + Request::retrieve_under( + *iv, + tag.clone(), + Descriptor::of(descriptor), + keyset.keyset_id(), + ) + }) + .collect(); + let count: usize = Pending::request(&keyset, requests, |responses| { + Ok(responses.drain_retrieved().count()) + }) + .await + .unwrap(); + + assert_eq!(count, 2, "both keys were retrieved"); + assert_eq!( + cipher.kms().retrieve_descriptors(), + vec![vec!["users/name".to_owned(), "users/email".to_owned()]] + ); + } + + /// ZeroKMS copies a descriptor into a fixed 512-byte block without a + /// length check, so an over-long one must never reach it: the batch is + /// refused before either call, with no key minted or retrieved. + #[tokio::test] + async fn an_over_long_descriptor_is_refused_before_any_call() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let long = Descriptor::of("a".repeat(Descriptor::MAX_LEN + 1)); + let requests = vec![ + Request::generate_under(Descriptor::of("users/email")), + Request::generate_under(long.clone()), + ]; + let Err(err) = dispatch(&cipher, Some(keyset.keyset_id()), requests).await else { + panic!("an over-long descriptor must be refused"); + }; + assert!( + matches!(err, Error::DescriptorTooLong { len } if len == Descriptor::MAX_LEN + 1), + "{err}" + ); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "no key is minted for a batch that is refused" + ); + + let mut pairs = generating_pairs(&keyset, 1).await.unwrap(); + let (iv, tag) = pairs.remove(0); + let requests = vec![Request::retrieve_under(iv, tag, long, keyset.keyset_id())]; + let Err(err) = dispatch(&cipher, Some(keyset.keyset_id()), requests).await else { + panic!("an over-long descriptor must be refused"); + }; + assert!(matches!(err, Error::DescriptorTooLong { .. }), "{err}"); + assert_eq!( + cipher.kms().retrieve_calls(), + 0, + "and none is retrieved either" + ); + + // At the limit is fine. + let before = cipher.kms().generate_calls(); + + let requests = vec![Request::generate_under(Descriptor::of( + "a".repeat(Descriptor::MAX_LEN), + ))]; + assert!( + dispatch(&cipher, Some(keyset.keyset_id()), requests) + .await + .is_ok(), + "at the limit" + ); + assert_eq!( + cipher.kms().generate_calls(), + before + 1, + "a descriptor exactly at the limit is dispatched" + ); + } + + // ========================================================================= + // Keyset scope + // ========================================================================= + + /// A generate request needs a keyset to mint under, and only a + /// `KeysetCipher` scope has one: through the `StackCipher` it fails at + /// construction, with no I/O. + #[tokio::test] + async fn a_generate_request_through_the_client_scope_has_no_keyset() { + let cipher = cipher().await; + let pending: Pending<'_, Vec<u8>, _> = + Pending::request(&cipher, vec![Request::generate_under(d())], |responses| { + responses.next_generated_key().map(|key| key.tag) + }); + let result = pending.await; + + assert!(matches!(result, Err(Error::NoKeyset)), "{result:?}"); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "refused at construction, before any call" + ); + } + + /// Data keys are minted under the scope's keyset, and that is what + /// reaches ZeroKMS. + #[tokio::test] + async fn generates_are_minted_under_the_scopes_keyset() { + let cipher = cipher().await; + let tenant = cipher.keyset(Uuid::from_u128(9)).await.unwrap(); + generating(&tenant, 2).await.unwrap(); + + assert_eq!( + cipher.kms().generate_keysets(), + vec![Some(Uuid::from_u128(9))], + "the scope's keyset is what ZeroKMS is asked to mint under" + ); + } + + /// A retrieve request naming another keyset than the scope's is refused + /// at construction, before any key is retrieved. + #[tokio::test] + async fn a_retrieve_from_another_keyset_is_foreign_in_a_keyset_scope() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let other = Uuid::from_u128(2); + let pending: Pending<'_, usize, _> = Pending::request( + &keyset, + vec![Request::retrieve_under(Iv::default(), vec![1], d(), other)], + |responses| Ok(responses.drain_retrieved().count()), + ); + let result = pending.await; + + assert!( + matches!( + result, + Err(Error::ForeignKeyset { expected, found }) + if expected == keyset.keyset_id() && found == other + ), + "{result:?}" + ); + assert_eq!( + cipher.kms().retrieve_calls(), + 0, + "refused at construction, before any key is retrieved" + ); + } + + /// Scoping a pending built through the client (the constrained decrypt + /// path) applies the same rule to the requests it already carries. + #[tokio::test] + async fn scoping_an_unscoped_pending_refuses_its_foreign_retrieves() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let other = Uuid::from_u128(2); + let pending: Pending<'_, usize, _> = Pending::request( + &cipher, + vec![Request::retrieve_under(Iv::default(), vec![1], d(), other)], + |responses| Ok(responses.drain_retrieved().count()), + ); + let result = pending.scoped_to(keyset.keyset_id()).await; + + assert!( + matches!(result, Err(Error::ForeignKeyset { .. })), + "{result:?}" + ); + assert_eq!( + cipher.kms().retrieve_calls(), + 0, + "scoping applies the rule before any key is retrieved" + ); + } + + /// Two pendings scoped to different keysets are one tenant's row and + /// another's: merging them is a composition bug, caught with no I/O. + #[tokio::test] + async fn pendings_scoped_to_different_keysets_refuse_to_merge() { + let cipher = cipher().await; + let a = cipher.keyset(Uuid::from_u128(1)).await.unwrap(); + let b = cipher.keyset(Uuid::from_u128(2)).await.unwrap(); + + let result = generating(&a, 1).zip(generating(&b, 1)).await; + assert!( + matches!(result, Err(Error::KeysetMismatch { left, right }) + if left == Uuid::from_u128(1) && right == Uuid::from_u128(2)), + "{result:?}" + ); + + let result = Pending::all(&a, vec![generating(&a, 1), generating(&b, 1)]).await; + assert!( + matches!(result, Err(Error::KeysetMismatch { .. })), + "{result:?}" + ); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "a mismatched merge mints nothing" + ); + } + + /// Scoping a pending that already has a scope checks the two agree: the + /// same keyset again is a no-op, another one is the mismatch `zip` + /// reports — never a silent re-scope that mints under the second. + #[tokio::test] + async fn rescoping_to_another_keyset_is_a_mismatch() { + let cipher = cipher().await; + let a = cipher.keyset(Uuid::from_u128(1)).await.unwrap(); + + let result = generating(&a, 1).scoped_to(Uuid::from_u128(2)).await; + assert!( + matches!(result, Err(Error::KeysetMismatch { left, right }) + if left == Uuid::from_u128(1) && right == Uuid::from_u128(2)), + "{result:?}" + ); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "a mismatched scope mints nothing" + ); + + let tags = generating(&a, 1) + .scoped_to(a.keyset_id()) + .await + .expect("re-scoping to its own keyset changes nothing"); + assert_eq!( + tags.len(), + 1, + "rescoping to the same keyset should produce one tag" + ); + assert_eq!( + cipher.kms().generate_keysets(), + vec![Some(a.keyset_id())], + "minted under the one keyset it was scoped to" + ); + } + + /// The scope a pending is given is the scope it keeps: merged afterwards + /// with another tenant's pending, it is a mismatch, not an unscoped + /// value the other side's keyset absorbs. + #[tokio::test] + async fn a_scoped_pending_keeps_its_scope_through_a_merge() { + let cipher = cipher().await; + let a = cipher.keyset(Uuid::from_u128(1)).await.unwrap(); + let b = cipher.keyset(Uuid::from_u128(2)).await.unwrap(); + + let scoped = Pending::ready(&cipher, Ok(())).scoped_to(a.keyset_id()); + let result = scoped.zip(generating(&b, 1)).await; + assert!( + matches!(result, Err(Error::KeysetMismatch { left, right }) + if left == a.keyset_id() && right == b.keyset_id()), + "{result:?}" + ); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "a mismatched merge should not mint a key" + ); + } + + /// A generate built through the client scope fails where it is built, + /// so merging it into a tenant's batch afterwards cannot launder it into + /// a key minted under that tenant's keyset. + #[tokio::test] + async fn an_unscoped_generate_is_not_adopted_by_a_scoped_merge() { + let cipher = cipher().await; + let tenant = cipher.keyset(Uuid::from_u128(9)).await.unwrap(); + let unscoped: Pending<'_, Vec<u8>, _> = + Pending::request(&cipher, vec![Request::generate_under(d())], |responses| { + responses.next_generated_key().map(|key| key.tag) + }); + + let result = unscoped.zip(generating(&tenant, 1)).await; + assert!(matches!(result, Err(Error::NoKeyset)), "{result:?}"); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "the tenant's keyset mints nothing for it" + ); + } + + /// An unscoped pending merged with a scoped one takes the scope: a + /// ready value beside a tenant's data keys is still that tenant's batch. + #[tokio::test] + async fn an_unscoped_pending_merged_with_a_scoped_one_takes_the_scope() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let (n, tags) = Pending::ready(&cipher, Ok(7u32)) + .zip(generating(&keyset, 1)) + .await + .unwrap(); + + assert_eq!( + (n, tags.len()), + (7, 1), + "both sides resolve: the ready value and the minted key" + ); + assert_eq!( + cipher.kms().generate_keysets(), + vec![Some(keyset.keyset_id())], + "the merged assembly took the scoped side's keyset" + ); + } + + /// Through the client scope, retrieves from several keysets settle in one + /// assembly: one `retrieve_keys` call per keyset, in first-seen order, + /// with the keys back in request order. + #[tokio::test] + async fn retrieves_group_by_keyset_and_return_in_request_order() { + let cipher = cipher().await; + let a = cipher.keyset(Uuid::from_u128(1)).await.unwrap(); + let b = cipher.keyset(Uuid::from_u128(2)).await.unwrap(); + let mut from_a = generating_pairs(&a, 2).await.unwrap(); + let mut from_b = generating_pairs(&b, 1).await.unwrap(); + let (a1, a2) = (from_a.remove(0), from_a.remove(0)); + let b1 = from_b.remove(0); + + // Interleaved: A, B, A. + let requests = vec![ + Request::retrieve_under(a1.0, a1.1.clone(), d(), a.keyset_id()), + Request::retrieve_under(b1.0, b1.1.clone(), d(), b.keyset_id()), + Request::retrieve_under(a2.0, a2.1.clone(), d(), a.keyset_id()), + ]; + let ivs: Vec<Iv> = Pending::request(&cipher, requests, |responses| { + Ok(responses.drain_retrieved().map(|key| key.iv).collect()) + }) + .await + .unwrap(); + + assert_eq!( + ivs, + vec![a1.0, b1.0, a2.0], + "keys must come back in request order" + ); + assert_eq!( + cipher.kms().retrieve_calls(), + 2, + "two keysets, two retrieve_keys calls" + ); + assert_eq!( + cipher.kms().retrieve_keysets(), + vec![Some(a.keyset_id()), Some(b.keyset_id())], + "one call per keyset, first seen first" + ); + } +} diff --git a/packages/stack-encrypt/src/target/request.rs b/packages/stack-encrypt/src/target/request.rs new file mode 100644 index 000000000..10257b69d --- /dev/null +++ b/packages/stack-encrypt/src/target/request.rs @@ -0,0 +1,497 @@ +//! The ZeroKMS work a [`Pending`](super::Pending) carries, and the responses +//! it settles against. +//! +//! A [`Request`] is one unit of work — "generate a data key", "re-derive the +//! data key identified by this `iv` + `tag`". Requests accumulate as pendings +//! are combined, and awaiting the combined [`Pending`](super::Pending) turns +//! the whole accumulated list into **one** ZeroKMS call per kind. +//! [`Responses`] is what comes back: one queue per kind, in request order. +//! +//! The two halves are deliberately dumb — no I/O, no cipher, no futures — so +//! the response-scoping rules that keep one fulfilment from consuming a +//! sibling's key material are unit-testable on their own. + +use super::context::AeadContext; +use std::collections::VecDeque; + +use stack_kms::{DataKey, DataKeyWithTag, Iv}; +use uuid::Uuid; + +use crate::{Descriptor, Error}; + +/// One unit of ZeroKMS work a [`Pending`](super::Pending) needs: +/// constructible, otherwise opaque, so new request kinds (a PRF derivation, a +/// keyset override) can be added without breaking implementations. +#[derive(Debug, Clone)] +pub struct Request(RequestKind); + +#[derive(Debug, Clone)] +pub(super) enum RequestKind { + /// Generate one fresh data key under the pending's keyset, bound to + /// `descriptor`. + GenerateDataKey { descriptor: Descriptor }, + /// Re-derive the data key identified by `iv` + `tag`, under the + /// `descriptor` it was generated with, from the keyset it was minted + /// under. + RetrieveDataKey { + iv: Iv, + tag: Vec<u8>, + descriptor: Descriptor, + keyset_id: Uuid, + }, +} + +impl Request { + /// Request one fresh data key (encrypt side), minted under `context`. + /// + /// The [`Descriptor`] is rendered here rather than supplied, so a request + /// cannot carry a descriptor that disagrees with its own `context` + /// (ADR-0004, decision 4). ZeroKMS HMACs the descriptor into the key + /// `tag`, so the key re-derives only under the same one. What this does + /// not relate is the request to the leaf sealed with the key: a + /// [`SealedValue`](crate::SealedValue) is still assembled from raw parts + /// at the extension point, and its AEAD context is the caller's to keep + /// in agreement with this one. + /// + /// A descriptor is rendered from the context's parts, so the context + /// need only convert into an [`AeadContext`]: any `IntoContext` type a + /// [`StackCipherText`](crate::StackCipherText) seals under can request + /// the key it seals with, and a [`CallerContext`](super::CallerContext) + /// converts as it is. + pub fn generate_data_key(context: impl Into<AeadContext>) -> Self { + Self::generate_under(Descriptor::of(context.into())) + } + + /// Request re-derivation of the data key identified by `iv` + `tag` + /// (decrypt side), under `context` — which must be the one the key was + /// generated under, or ZeroKMS refuses — from `keyset_id`, the keyset it + /// was minted under (a [`SealedValue`] carries it). + /// + /// [`SealedValue`]: crate::SealedValue + pub fn retrieve_data_key( + iv: Iv, + tag: Vec<u8>, + context: impl Into<AeadContext>, + keyset_id: Uuid, + ) -> Self { + Self::retrieve_under(iv, tag, Descriptor::of(context.into()), keyset_id) + } + + /// [`generate_data_key`](Self::generate_data_key) over a descriptor that + /// has already been rendered. + /// + /// Crate-internal: the batching paths derive one descriptor from one + /// context and reuse it across every leaf of a tree, and re-rendering it + /// per request would cost a context encoding per leaf. The public + /// constructor takes the context so that a request's descriptor and the + /// context it was asked for under cannot disagree. + pub(crate) fn generate_under(descriptor: Descriptor) -> Self { + Self(RequestKind::GenerateDataKey { descriptor }) + } + + /// [`retrieve_data_key`](Self::retrieve_data_key) over an already + /// rendered descriptor. Crate-internal, as + /// [`generate_under`](Self::generate_under). + pub(crate) fn retrieve_under( + iv: Iv, + tag: Vec<u8>, + descriptor: Descriptor, + keyset_id: Uuid, + ) -> Self { + Self(RequestKind::RetrieveDataKey { + iv, + tag, + descriptor, + keyset_id, + }) + } + + /// Consume the request, yielding what it asks for. + pub(super) fn into_kind(self) -> RequestKind { + self.0 + } + + /// The keyset a retrieve request names; `None` for a generate request, + /// which mints under the pending's keyset. + pub(super) fn retrieve_keyset(&self) -> Option<Uuid> { + match &self.0 { + RequestKind::GenerateDataKey { .. } => None, + RequestKind::RetrieveDataKey { keyset_id, .. } => Some(*keyset_id), + } + } +} + +/// How many requests of each kind `requests` holds, as +/// `(generate, retrieve)` — the shape a fulfilment is scoped to. +pub(super) fn tally(requests: &[Request]) -> (usize, usize) { + let (mut generate, mut retrieve) = (0usize, 0usize); + for request in requests { + match request.0 { + RequestKind::GenerateDataKey { .. } => generate += 1, + RequestKind::RetrieveDataKey { .. } => retrieve += 1, + } + } + (generate, retrieve) +} + +/// The responses a fulfilment draws from — one queue per request kind, in +/// request order. A fulfilment sees exactly the responses its own requests +/// asked for (never a neighbour's), and drawing past that is +/// [`Error::ResponseShape`]. +pub struct Responses { + generated: VecDeque<DataKeyWithTag>, + retrieved: VecDeque<DataKey>, +} + +impl Responses { + /// The full response set of one batched dispatch, in request order. + pub(super) fn new(generated: Vec<DataKeyWithTag>, retrieved: Vec<DataKey>) -> Self { + Self { + generated: generated.into(), + retrieved: retrieved.into(), + } + } + + /// The next generated data key, in [`Request::generate_data_key`] order. + pub fn next_generated_key(&mut self) -> Result<DataKeyWithTag, Error> { + self.generated.pop_front().ok_or(Error::ResponseShape) + } + + /// The next retrieved data key, in [`Request::retrieve_data_key`] order. + pub fn next_retrieved_key(&mut self) -> Result<DataKey, Error> { + self.retrieved.pop_front().ok_or(Error::ResponseShape) + } + + /// Split off the first `generated` + `retrieved` responses — the + /// per-fulfilment view [`Pending::request`](super::Pending::request) + /// scopes each fulfilment to. Short of either count is + /// [`Error::ResponseShape`], and nothing is consumed. + pub(super) fn split_front( + &mut self, + generated: usize, + retrieved: usize, + ) -> Result<Responses, Error> { + if self.generated.len() < generated || self.retrieved.len() < retrieved { + return Err(Error::ResponseShape); + } + Ok(Responses { + generated: self.generated.drain(..generated).collect(), + retrieved: self.retrieved.drain(..retrieved).collect(), + }) + } + + /// Whether every response in this view has been drawn. Checked after a + /// fulfilment returns: a fulfilment that asked for a key and left it + /// behind is a composition bug, not a cheaper request. + pub(super) fn is_exhausted(&self) -> bool { + self.generated.is_empty() && self.retrieved.is_empty() + } + + pub(crate) fn drain_generated(&mut self) -> impl Iterator<Item = DataKeyWithTag> + '_ { + self.generated.drain(..) + } + + pub(crate) fn drain_retrieved(&mut self) -> impl Iterator<Item = DataKey> + '_ { + self.retrieved.drain(..) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + + use std::borrow::Cow; + + use stack_kms::{DataKeySource, FakeDataKeySource, GenerateKeyPayload, RetrieveKeyPayload}; + + use super::*; + use crate::{ContextPiece, IntoContext, MaybeEmpty, NonEmpty}; + + fn d() -> Descriptor { + Descriptor::of("test/field") + } + + /// `n` real generated keys, plus the retrieved keys for the same `n` + /// (`iv`, `tag`) pairs — the stub round-trips, which is all these tests + /// need from it. + async fn key_pairs(n: usize) -> (Vec<DataKeyWithTag>, Vec<DataKey>) { + let kms = FakeDataKeySource::new(); + let generated = kms + .generate_keys( + (0..n) + .map(|_| GenerateKeyPayload::new("", Cow::Owned(Vec::new()))) + .collect(), + None, + None, + ) + .await + .unwrap(); + let retrieved = kms + .retrieve_keys( + generated + .iter() + .map(|key| RetrieveKeyPayload::new(key.key.iv, "", &key.tag)) + .collect(), + None, + None, + ) + .await + .unwrap(); + (generated, retrieved) + } + + fn ks() -> Uuid { + Uuid::from_u128(7) + } + + async fn responses(generated: usize, retrieved: usize) -> Responses { + let (g, _) = key_pairs(generated).await; + let (_, r) = key_pairs(retrieved).await; + Responses::new(g, r) + } + + #[test] + fn tally_counts_nothing_for_no_requests() { + assert_eq!(tally(&[]), (0, 0)); + } + + #[test] + fn tally_separates_the_two_kinds() { + let requests = vec![ + Request::generate_under(d()), + Request::retrieve_under(Iv::default(), vec![1], d(), ks()), + Request::generate_under(d()), + Request::retrieve_under(Iv::default(), vec![2], d(), ks()), + Request::generate_under(d()), + ]; + assert_eq!(tally(&requests), (3, 2)); + } + + /// The public constructors render the descriptor themselves, from the + /// context, so a request cannot carry one that disagrees with its own + /// context (ADR-0004, decision 4) — and rendering through an + /// `AeadContext` preserves the context's structured identity. + #[test] + fn a_public_request_renders_its_descriptor_from_its_context() { + let context = crate::nonempty!("users/email").with(7u64); + let expected = Descriptor::of(context); + match Request::generate_data_key(context).into_kind() { + RequestKind::GenerateDataKey { descriptor } => assert_eq!(descriptor, expected), + RequestKind::RetrieveDataKey { .. } => panic!("expected a generate request"), + } + match Request::retrieve_data_key(Iv::default(), vec![1], context, ks()).into_kind() { + RequestKind::RetrieveDataKey { descriptor, .. } => assert_eq!(descriptor, expected), + RequestKind::GenerateDataKey { .. } => panic!("expected a retrieve request"), + } + } + + /// A plain `IntoContext` type, as an `AeadContext` target declares. + #[derive(Clone)] + struct Tenant(String); + impl MaybeEmpty for Tenant { + fn is_empty(&self) -> bool { + self.0.is_empty() + } + } + impl<'a> IntoContext<'a> for Tenant { + fn into_context(self) -> ContextPiece<'a> { + self.0.into_context() + } + } + + /// A descriptor is rendered from the context's parts, so the context a + /// `StackCipherText` seals under — any `IntoContext` type, as an + /// `AeadContext` — can request the data key it seals with. Requiring a + /// `CallerContext` here would shut an `AeadContext` target out of the + /// `Pending::request` extension point for no reason. + #[test] + fn an_aead_only_context_can_request_a_data_key() { + let context = NonEmpty::new(Tenant("acme".into())).unwrap(); + match Request::generate_data_key(context.clone()).into_kind() { + RequestKind::GenerateDataKey { descriptor } => assert_eq!(descriptor.as_str(), "acme"), + RequestKind::RetrieveDataKey { .. } => panic!("expected a generate request"), + } + match Request::retrieve_data_key(Iv::default(), vec![1], context, ks()).into_kind() { + RequestKind::RetrieveDataKey { descriptor, .. } => { + assert_eq!(descriptor.as_str(), "acme") + } + RequestKind::GenerateDataKey { .. } => panic!("expected a retrieve request"), + } + } + + #[test] + fn a_generate_request_carries_its_descriptor() { + match Request::generate_under(d()).into_kind() { + RequestKind::GenerateDataKey { descriptor } => assert_eq!(descriptor, d()), + RequestKind::RetrieveDataKey { .. } => panic!("expected a generate request"), + } + } + + #[test] + fn a_retrieve_request_carries_its_iv_tag_and_descriptor() { + let request = Request::retrieve_under(Iv::default(), vec![7, 8, 9], d(), ks()); + match request.into_kind() { + RequestKind::RetrieveDataKey { + iv, + tag, + descriptor, + keyset_id, + } => { + assert_eq!(keyset_id, ks()); + assert_eq!(iv, Iv::default()); + assert_eq!(tag, vec![7, 8, 9]); + assert_eq!(descriptor, d()); + } + RequestKind::GenerateDataKey { .. } => panic!("expected a retrieve request"), + } + } + + #[tokio::test] + async fn generated_keys_come_back_in_request_order() { + let (generated, _) = key_pairs(3).await; + let tags: Vec<Vec<u8>> = generated.iter().map(|key| key.tag.clone()).collect(); + let mut responses = Responses::new(generated, Vec::new()); + + for tag in tags { + assert_eq!(responses.next_generated_key().unwrap().tag, tag); + } + } + + #[tokio::test] + async fn retrieved_keys_come_back_in_request_order() { + let (_, retrieved) = key_pairs(3).await; + let ivs: Vec<Iv> = retrieved.iter().map(|key| key.iv).collect(); + let mut responses = Responses::new(Vec::new(), retrieved); + + for iv in ivs { + assert_eq!(responses.next_retrieved_key().unwrap().iv, iv); + } + } + + #[tokio::test] + async fn drawing_a_generated_key_past_the_end_is_a_response_shape_error() { + let mut responses = responses(1, 0).await; + assert!(responses.next_generated_key().is_ok()); + assert!(matches!( + responses.next_generated_key(), + Err(Error::ResponseShape) + )); + } + + #[tokio::test] + async fn drawing_a_retrieved_key_past_the_end_is_a_response_shape_error() { + let mut responses = responses(0, 1).await; + assert!(responses.next_retrieved_key().is_ok()); + assert!(matches!( + responses.next_retrieved_key(), + Err(Error::ResponseShape) + )); + } + + /// The kinds are separate queues: a generate response can never be drawn + /// as a retrieve response, however many of the other kind are waiting. + #[tokio::test] + async fn the_two_kinds_do_not_substitute_for_each_other() { + let mut generated_only = responses(2, 0).await; + assert!(matches!( + generated_only.next_retrieved_key(), + Err(Error::ResponseShape) + )); + + let mut retrieved_only = responses(0, 2).await; + assert!(matches!( + retrieved_only.next_generated_key(), + Err(Error::ResponseShape) + )); + } + + #[tokio::test] + async fn split_front_takes_exactly_what_was_asked_for() { + let mut responses = responses(3, 2).await; + let own = responses.split_front(2, 1).unwrap(); + + assert_eq!((own.generated.len(), own.retrieved.len()), (2, 1)); + assert_eq!( + (responses.generated.len(), responses.retrieved.len()), + (1, 1) + ); + } + + /// The scoping guarantee: a fulfilment's view holds *its* responses, and + /// the responses left behind are the ones its siblings will draw. + #[tokio::test] + async fn split_front_takes_from_the_front_and_leaves_the_rest() { + let (generated, _) = key_pairs(3).await; + let tags: Vec<Vec<u8>> = generated.iter().map(|key| key.tag.clone()).collect(); + let mut responses = Responses::new(generated, Vec::new()); + + let mut first = responses.split_front(1, 0).unwrap(); + assert_eq!(first.next_generated_key().unwrap().tag, tags[0]); + + let mut rest = responses.split_front(2, 0).unwrap(); + assert_eq!(rest.next_generated_key().unwrap().tag, tags[1]); + assert_eq!(rest.next_generated_key().unwrap().tag, tags[2]); + } + + #[tokio::test] + async fn split_front_of_nothing_yields_an_empty_view() { + let mut responses = responses(2, 2).await; + let mut own = responses.split_front(0, 0).unwrap(); + + assert!(matches!( + own.next_generated_key(), + Err(Error::ResponseShape) + )); + assert!(matches!( + own.next_retrieved_key(), + Err(Error::ResponseShape) + )); + // The siblings' responses are untouched. + assert_eq!( + (responses.generated.len(), responses.retrieved.len()), + (2, 2) + ); + } + + #[tokio::test] + async fn split_front_short_of_generated_responses_errors_without_consuming() { + let mut responses = responses(1, 0).await; + assert!(matches!( + responses.split_front(2, 0), + Err(Error::ResponseShape) + )); + assert_eq!(responses.generated.len(), 1); + } + + #[tokio::test] + async fn split_front_short_of_retrieved_responses_errors_without_consuming() { + let mut responses = responses(2, 1).await; + assert!(matches!( + responses.split_front(2, 2), + Err(Error::ResponseShape) + )); + // Neither queue was drained: the check happens before the split. + assert_eq!( + (responses.generated.len(), responses.retrieved.len()), + (2, 1) + ); + } + + #[tokio::test] + async fn draining_consumes_every_response_of_that_kind() { + let mut responses = responses(3, 2).await; + + assert_eq!(responses.drain_generated().count(), 3); + assert_eq!(responses.retrieved.len(), 2); + assert_eq!(responses.drain_retrieved().count(), 2); + + assert!(matches!( + responses.next_generated_key(), + Err(Error::ResponseShape) + )); + assert!(matches!( + responses.next_retrieved_key(), + Err(Error::ResponseShape) + )); + } +} diff --git a/packages/stack-encrypt/src/target/transcode.rs b/packages/stack-encrypt/src/target/transcode.rs new file mode 100644 index 000000000..53949a1f2 --- /dev/null +++ b/packages/stack-encrypt/src/target/transcode.rs @@ -0,0 +1,212 @@ +//! Consuming readers over native encryption output. +//! +//! A destination that stores encrypted output in its own shape (an EQL +//! envelope, say) implements [`Transcode`] with a [`Visitor`]; the cipher's +//! native output is a [`Reader`] that drives it. Reading moves the existing +//! leaves, markers, and terms into the destination as they are: nothing is +//! serialised, re-encrypted, or gathered into an intermediate tree first. +//! +//! What a reader hands over is what the cipher produced, no more: a sealed +//! leaf or marker is authenticated ciphertext, but passthrough metadata and +//! terms are not, and a visitor must not present them as such. +use crate::sem::{EqualityTerm, MatchConfig, MatchTerm, OpeTerm, OreTerm}; +use crate::{BoxedPassthrough, CipherText, Error, SealedValue, StackCipherText}; + +/// An encrypted output that can drive a destination visitor. +/// +/// Implemented by the native [`StackCipherText`] tree and by every term +/// type; [`Encryption::transcode`](super::Encryption::transcode) calls it on +/// an operation's completed output. +pub trait Reader: Sized { + /// Hand this output to `visitor`, consuming it. + /// + /// # Errors + /// + /// Whatever the visitor returns; for its default methods, that is + /// [`Error::UnsupportedShape`]. + fn read<V: Visitor>(self, visitor: V) -> Result<V::Value, Error>; +} +/// How a destination is built from each shape of encrypted output. +/// +/// Every method has a default that refuses with [`Error::UnsupportedShape`], +/// so a destination implements only the shapes it stores: a scalar column +/// takes `sealed` and nothing else, and is then refused a sequence rather +/// than handed one flattened. Markers arrive sealed: none of these methods +/// authenticates what it is given. +pub trait Visitor: Sized { + /// The destination this visitor builds. + type Value; + /// One sealed leaf: a scalar's ciphertext. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. + fn sealed(self, _: SealedValue) -> Result<Self::Value, Error> { + Err(Error::UnsupportedShape) + } + /// A non-empty sequence, read one item at a time. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. + fn sequence<R: SequenceReader>(self, _: R) -> Result<Self::Value, Error> { + Err(Error::UnsupportedShape) + } + /// A non-empty map, read one entry at a time. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. + fn map<R: MapReader>(self, _: R) -> Result<Self::Value, Error> { + Err(Error::UnsupportedShape) + } + /// The sealed marker of an absent optional. It is ciphertext, not a + /// null: opening it under the wrong context fails like any leaf. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. + fn absent(self, _: SealedValue) -> Result<Self::Value, Error> { + Err(Error::UnsupportedShape) + } + /// The sealed marker of an empty sequence. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. + fn empty_sequence(self, _: SealedValue) -> Result<Self::Value, Error> { + Err(Error::UnsupportedShape) + } + /// The sealed marker of an empty map. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. + fn empty_map(self, _: SealedValue) -> Result<Self::Value, Error> { + Err(Error::UnsupportedShape) + } + /// Passthrough metadata the plaintext carried alongside its encrypted + /// fields. Not authenticated: the reader hands it over as it was given. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. + fn passthrough(self, _: BoxedPassthrough) -> Result<Self::Value, Error> { + Err(Error::UnsupportedShape) + } + /// An equality term. One-way, and not authenticated. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. + fn equality(self, _: EqualityTerm) -> Result<Self::Value, Error> { + Err(Error::UnsupportedShape) + } + /// A match term. One-way, and not authenticated. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. + fn matching<O: MatchConfig>(self, _: MatchTerm<O>) -> Result<Self::Value, Error> { + Err(Error::UnsupportedShape) + } + /// An order-revealing term. One-way, and not authenticated. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. + fn ore<T: cllw_ore::CllwOreEncrypt>(self, _: OreTerm<T>) -> Result<Self::Value, Error> { + Err(Error::UnsupportedShape) + } + /// An order-preserving term. One-way, and not authenticated. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. + fn ope<T: cllw_ore::CllwOpeEncrypt>(self, _: OpeTerm<T>) -> Result<Self::Value, Error> { + Err(Error::UnsupportedShape) + } +} +/// A destination that names its visitor, so an +/// [`Encryption`](super::Encryption) can be transcoded into it by type alone. +/// Choosing the visitor sees neither plaintext nor cipher. +pub trait Transcode: Sized { + /// The visitor that builds this destination. + type Visitor: Visitor<Value = Self>; + /// A fresh visitor for one output. + fn visitor() -> Self::Visitor; +} +/// Streaming access to an existing sequence. Each item is itself a +/// [`Reader`], so nesting is read the same way; the child owns its output. +pub trait SequenceReader { + /// One item of the sequence. + type Item: Reader; + /// The next item, in the sequence's order. + fn next(&mut self) -> Option<Self::Item>; + /// How many items remain — a capacity hint, not a promise. + fn remaining(&self) -> usize; +} +/// Streaming access to an existing map. +/// +/// Keys keep their original spelling and order, and a destination must store +/// them exactly: Vitamin C derives each entry's authenticated context from +/// its key, so a renamed key fails to open. +pub trait MapReader { + /// One entry's value. + type Item: Reader; + /// The next entry, in the map's order. + fn next(&mut self) -> Option<(String, Self::Item)>; + /// How many entries remain — a capacity hint, not a promise. + fn remaining(&self) -> usize; +} +impl SequenceReader for std::vec::IntoIter<StackCipherText> { + type Item = StackCipherText; + fn next(&mut self) -> Option<Self::Item> { + Iterator::next(self) + } + fn remaining(&self) -> usize { + self.len() + } +} +impl MapReader for std::vec::IntoIter<(String, StackCipherText)> { + type Item = StackCipherText; + fn next(&mut self) -> Option<(String, Self::Item)> { + Iterator::next(self) + } + fn remaining(&self) -> usize { + self.len() + } +} +impl Reader for StackCipherText { + fn read<V: Visitor>(self, visitor: V) -> Result<V::Value, Error> { + match self { + CipherText::Single(leaf) => visitor.sealed(leaf), + CipherText::Sequence(items) => visitor.sequence(items.into_iter()), + CipherText::Map(entries) => visitor.map(entries.into_iter()), + CipherText::None(marker) => visitor.absent(marker), + CipherText::EmptySequence(marker) => visitor.empty_sequence(marker), + CipherText::EmptyMap(marker) => visitor.empty_map(marker), + CipherText::Passthrough(value) => visitor.passthrough(value), + } + } +} +impl Reader for EqualityTerm { + fn read<V: Visitor>(self, visitor: V) -> Result<V::Value, Error> { + visitor.equality(self) + } +} +impl<O: MatchConfig> Reader for MatchTerm<O> { + fn read<V: Visitor>(self, visitor: V) -> Result<V::Value, Error> { + visitor.matching(self) + } +} +impl<T: cllw_ore::CllwOreEncrypt> Reader for OreTerm<T> { + fn read<V: Visitor>(self, visitor: V) -> Result<V::Value, Error> { + visitor.ore(self) + } +} +impl<T: cllw_ore::CllwOpeEncrypt> Reader for OpeTerm<T> { + fn read<V: Visitor>(self, visitor: V) -> Result<V::Value, Error> { + visitor.ope(self) + } +} diff --git a/packages/stack-encrypt/tasks.toml b/packages/stack-encrypt/tasks.toml new file mode 100644 index 000000000..872417224 --- /dev/null +++ b/packages/stack-encrypt/tasks.toml @@ -0,0 +1,74 @@ +["crap:stack-encrypt"] +description = "Gate stack-encrypt on the CRAP (Change Risk Anti-Patterns) metric — fails when a complex, under-tested function exceeds the threshold" +run = [ + # Instrument and run the unit and integration tests, emitting LCOV coverage + # that `cargo crap` consumes. `--all-features` so the `dynamic` module (the + # FFI value model every binding funnels through) is scored, not skipped as + # uninstrumented. The trybuild UI suite is excluded: it compiles the derive's + # compile-fail cases in a scratch cargo project, which contributes nothing to + # this crate's coverage and only slows the instrumented run. + "mise x --env test -- cargo llvm-cov nextest -p stack-encrypt --all-features -E 'not binary(ui)' --lcov --output-path {{config_root}}/target/stack-encrypt-lcov.info", + # Score every production function. Excludes test code, the examples, and the + # detached fuzz crate (which lives under this package's directory and would + # otherwise be walked as production source). `--fail-above` exits non-zero + # when any function's CRAP score exceeds the threshold from the workspace-root + # .cargo-crap.toml (30), so this gates both local runs and CI. + # NOTE: this must stay the LAST command — the crap-stack-encrypt.yml CI + # workflow runs this task and relies on mise appending its trailing args + # (e.g. --format github) to this `cargo crap` invocation. + "mise x --env test -- cargo crap --path packages/stack-encrypt --lcov {{config_root}}/target/stack-encrypt-lcov.info --exclude 'examples/**' --exclude 'fuzz/**' --exclude '**/tests/**' --fail-above", +] + +# Fuzz the frozen v1 leaf byte decoder (`SealedValue::from_bytes`, also +# `TryFrom<&[u8]>`). This is what a stored ciphertext column comes back +# through, so it is the crate's widest untrusted-input parser: it must never +# panic, and a leaf it accepts must re-encode to exactly the bytes it was +# decoded from (the format is documented as lossless). Same flags and +# rationale as `fuzz:access-key` in stack-auth: nightly, `--sanitizer none` +# (pure safe Rust), and the native host triple. The fuzz crate lives in +# `packages/stack-encrypt/fuzz/` (detached). +["fuzz:sealed-value"] +description = "Fuzz stack-encrypt's frozen leaf byte decoder, SealedValue::from_bytes (libFuzzer, nightly, 60s default)" +dir = "{{config_root}}/packages/stack-encrypt" +run = "mise x --env test -- cargo +nightly fuzz run sealed_value_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" + +# Fuzz the SEM index-term byte decoders — every `from_bytes` / `TryFrom<&[u8]>` +# a stored term comes back through (equality, match, ORE and OPE). One target +# for all four: each is a length or range check over the same untrusted +# slice, so a separate campaign per kind would only split the corpus. +["fuzz:term-decode"] +description = "Fuzz stack-encrypt's SEM index-term byte decoders (libFuzzer, nightly, 60s default)" +dir = "{{config_root}}/packages/stack-encrypt" +run = "mise x --env test -- cargo +nightly fuzz run term_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" + +# Fuzz the stored-record preflight (`dynamic::record::check_record`) with +# structure-aware inputs: an `Arbitrary`-derived mirror of a plan value and +# a ciphertext tree, checked against a model of the documented record rules +# — in particular that a passthrough under a field's `"c"` is refused, which +# is what stops a rewritten stored tree being reported as a decrypt. +["fuzz:check-record"] +description = "Fuzz stack-encrypt's stored-record preflight against a model of its rules (structure-aware, libFuzzer, nightly, 60s default)" +dir = "{{config_root}}/packages/stack-encrypt" +run = "mise x --env test -- cargo +nightly fuzz run check_record --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" + +# Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc +# warning fails the build. Doc *examples* are `test:doc:stack-encrypt`. Both run +# with all features so nothing feature-gated goes unchecked; the root `doc` +# task fans out over every `doc:<crate>`. +["doc:stack-encrypt"] +description = "Build docs for stack-encrypt with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-encrypt --no-deps --all-features" + +["test:doc:stack-encrypt"] +description = "Run documentation tests for stack-encrypt" +run = "mise x --env test -- cargo test -p stack-encrypt --doc --all-features" + +# Mutation testing (cargo-mutants) over the whole crate: what the per-PR +# `--in-diff` gate (.github/workflows/mutants.yml) does for changed lines only. +# Reads .cargo/mutants.toml (all features, nextest, the slow-test filter, +# timeouts). About 60 minutes with four jobs on a laptop. The +# trybuild UI suite is filtered out of the per-mutant run by the shared config. +["mutants:stack-encrypt"] +description = "Full mutation-testing sweep of stack-encrypt (cargo-mutants; ~60 min)" +run = "mise x --env test -- cargo mutants -p stack-encrypt --jobs 4" diff --git a/packages/stack-encrypt/tests/common/mod.rs b/packages/stack-encrypt/tests/common/mod.rs new file mode 100644 index 000000000..bd20c003f --- /dev/null +++ b/packages/stack-encrypt/tests/common/mod.rs @@ -0,0 +1,198 @@ +//! Fixtures shared by the integration test binaries. +//! +//! [`CountingSource`] is the fake source with ZeroKMS *call* counters (not +//! key counters): the design's whole claim is that an assembly of any size +//! settles in one batched call per request kind, and the tests hold it to +//! that. + +// Each test binary uses a subset of these. +#![allow(dead_code)] + +use std::borrow::Cow; +use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; +use std::sync::{Arc, Mutex}; + +use stack_encrypt::StackCipher; +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, + IndexKey, IndexKeySource, RetrieveKeyPayload, UnverifiedContext, +}; +use uuid::Uuid; + +/// A cipher over the deterministic fake source. The fake index key is +/// deterministic per keyset, so two separately built ciphers stand in for the +/// write path and a query path in another process. +pub async fn stack_cipher() -> StackCipher<FakeDataKeySource> { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher") +} + +pub struct CountingSource { + inner: FakeDataKeySource, + generate_calls: Arc<AtomicUsize>, + retrieve_calls: Arc<AtomicUsize>, +} + +impl CountingSource { + pub fn new() -> Self { + Self { + inner: FakeDataKeySource::new(), + generate_calls: Arc::new(AtomicUsize::new(0)), + retrieve_calls: Arc::new(AtomicUsize::new(0)), + } + } + + pub fn counters(&self) -> (Arc<AtomicUsize>, Arc<AtomicUsize>) { + (self.generate_calls.clone(), self.retrieve_calls.clone()) + } +} + +impl DataKeySource for CountingSource { + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, stack_kms::Error> { + self.generate_calls.fetch_add(1, AtomicOrdering::SeqCst); + self.inner + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, stack_kms::Error> { + self.retrieve_calls.fetch_add(1, AtomicOrdering::SeqCst); + self.inner + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } +} + +impl IndexKeySource for CountingSource { + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.inner.load_index_key(keyset_id).await + } +} + +/// A cipher over [`CountingSource`], with its `(generate, retrieve)` call +/// counters. +pub async fn counting_cipher() -> ( + StackCipher<CountingSource>, + Arc<AtomicUsize>, + Arc<AtomicUsize>, +) { + let source = CountingSource::new(); + let (generates, retrieves) = source.counters(); + let cipher = StackCipher::builder() + .kms(source) + .init() + .await + .expect("build cipher"); + (cipher, generates, retrieves) +} + +/// Every descriptor sent to ZeroKMS, per call, in payload order — what the +/// fake ignores but the real service binds into the key tag. Tests assert +/// against this, never against the fake's (non-)enforcement. +#[derive(Debug, Clone, Default)] +pub struct SentDescriptors { + pub generate: Vec<Vec<String>>, + pub retrieve: Vec<Vec<String>>, +} + +impl SentDescriptors { + /// Every generate-side descriptor, all calls flattened. + pub fn generated(&self) -> Vec<String> { + self.generate.iter().flatten().cloned().collect() + } + + /// Every retrieve-side descriptor, all calls flattened. + pub fn retrieved(&self) -> Vec<String> { + self.retrieve.iter().flatten().cloned().collect() + } +} + +/// The fake source, recording the descriptor of every payload it is sent. +pub struct RecordingSource { + inner: FakeDataKeySource, + sent: Arc<Mutex<SentDescriptors>>, +} + +impl RecordingSource { + pub fn new() -> Self { + Self { + inner: FakeDataKeySource::new(), + sent: Arc::new(Mutex::new(SentDescriptors::default())), + } + } + + pub fn sent(&self) -> Arc<Mutex<SentDescriptors>> { + self.sent.clone() + } +} + +impl DataKeySource for RecordingSource { + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, stack_kms::Error> { + self.sent + .lock() + .expect("lock") + .generate + .push(payloads.iter().map(|p| p.descriptor.to_owned()).collect()); + self.inner + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, stack_kms::Error> { + self.sent + .lock() + .expect("lock") + .retrieve + .push(payloads.iter().map(|p| p.descriptor.to_owned()).collect()); + self.inner + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } +} + +impl IndexKeySource for RecordingSource { + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.inner.load_index_key(keyset_id).await + } +} + +/// A cipher over [`RecordingSource`], with the descriptors it sends. +pub async fn recording_cipher() -> (StackCipher<RecordingSource>, Arc<Mutex<SentDescriptors>>) { + let source = RecordingSource::new(); + let sent = source.sent(); + let cipher = StackCipher::builder() + .kms(source) + .init() + .await + .expect("build cipher"); + (cipher, sent) +} diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs new file mode 100644 index 000000000..b8bcd8157 --- /dev/null +++ b/packages/stack-encrypt/tests/derive.rs @@ -0,0 +1,844 @@ +//! `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`: the derived impls are the +//! hand-written composite in `target.rs`, emitted — same terms, same decrypt +//! mirror, same one-batched-call settlement — plus what only a derive makes +//! cheap: sources listed or left generic, structs encrypted field by field, +//! and fields that are not derived at all. + +mod common; + +use std::sync::atomic::Ordering as AtomicOrdering; + +use cllw_ore::CllwOreEncrypt; +use common::{counting_cipher, stack_cipher}; +use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; +use stack_encrypt::target::{AeadContext, DecryptFrom, EncryptInto}; +use stack_encrypt::{ + nonempty, ContextPiece, DecryptField, DecryptInto, Decryptable, EncryptFrom, Error, + IntoContext, MaybeEmpty, NonEmpty, StackCipherText, +}; + +// --- Records: every field from one plaintext, under one context ------------- + +/// The hand-written record in `target.rs`, derived: an encrypted `u32` +/// stored as its ciphertext plus an equality term and an ORE term. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct EncryptedAge { + c: StackCipherText, + hm: EqualityTerm, + ob: OreTerm<u32>, +} + +#[tokio::test] +async fn a_derived_record_is_the_hand_written_one() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = stack_cipher().await; + let generator = generator.default_keyset(); + + let record: EncryptedAge = 42u32 + .encrypt_into_with_context(&keyset, nonempty!("users/age")) + .await + .unwrap(); + + // Each term is what the leaf derives on its own, so query terms built + // leaf-by-leaf find records encrypted as composites. + let hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, nonempty!("users/age")) + .await + .unwrap(); + let ob: OreTerm<u32> = 42u32 + .encrypt_into_with_context(&generator, nonempty!("users/age")) + .await + .unwrap(); + assert_eq!(record.hm, hm); + assert_eq!(record.ob, ob); + + // And the decrypt mirror opens the ciphertext field. + let age: u32 = record + .decrypt_into(&cipher, nonempty!("users/age")) + .await + .unwrap(); + assert_eq!(age, 42); +} + +/// No `plaintext`: one impl generic over it, accepting whatever every leaf +/// accepts — here any text type, since `MatchTerm` wants `AsRef<str>` — and +/// decrypting to whatever the ciphertext field opens to. +#[derive(EncryptFrom, DecryptInto)] +struct SearchableText { + c: StackCipherText, + hm: EqualityTerm, + m: MatchTerm, +} + +/// Tuple structs assign by index. +#[derive(EncryptFrom)] +struct Pair(StackCipherText, EqualityTerm); + +/// A record may declare `'__k` itself; the declaration API no longer adds +/// a keyset lifetime. Compiling is the test. +#[derive(EncryptFrom)] +#[stash(plaintext = u32)] +#[allow(dead_code)] +struct Borrowed<'__k> { + c: StackCipherText, + #[stash(default)] + label: Option<&'__k str>, +} + +/// The declaration's source lifetime steps aside for the record's own, +/// including when the first fallback name is also taken. +#[derive(EncryptFrom)] +#[stash(plaintext = u32)] +#[allow(dead_code)] +struct BorrowedSource<'__source, '__source_> { + c: StackCipherText, + #[stash(default)] + label: Option<&'__source str>, + #[stash(default)] + other: Option<&'__source_ str>, +} + +/// The record's own generics (and their bounds) are carried through, and the +/// where clause makes `Tagged<T>` accept exactly `T` — the ORE term is typed +/// by its source. A generic record's one-ciphertext check runs when the +/// record is first used rather than where it is defined. +#[derive(EncryptFrom, DecryptInto)] +struct Tagged<T: CllwOreEncrypt> { + c: StackCipherText, + ob: OreTerm<T>, +} + +#[tokio::test] +async fn a_generic_plaintext_record_accepts_what_its_leaves_accept() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = stack_cipher().await; + let generator = generator.default_keyset(); + + let record: SearchableText = "alice" + .to_string() + .encrypt_into_with_context(&keyset, nonempty!("users/name")) + .await + .unwrap(); + let hm: EqualityTerm = "alice" + .to_string() + .encrypt_into_with_context(&generator, nonempty!("users/name")) + .await + .unwrap(); + let m: MatchTerm = "alice" + .to_string() + .encrypt_into_with_context(&generator, nonempty!("users/name")) + .await + .unwrap(); + assert_eq!(record.hm, hm); + assert_eq!(record.m, m); + let name: String = record + .decrypt_into(&cipher, nonempty!("users/name")) + .await + .unwrap(); + assert_eq!(name, "alice"); + + let pair: Pair = "bob" + .encrypt_into_with_context(&keyset, nonempty!("users/name")) + .await + .unwrap(); + let hm: EqualityTerm = "bob" + .encrypt_into_with_context(&generator, nonempty!("users/name")) + .await + .unwrap(); + assert_eq!(pair.1, hm); + let name: String = pair + .0 + .decrypt_into(&cipher, nonempty!("users/name")) + .await + .unwrap(); + assert_eq!(name, "bob"); + + let tagged: Tagged<u32> = 7u32 + .encrypt_into_with_context(&keyset, nonempty!("users/score")) + .await + .unwrap(); + let ob: OreTerm<u32> = 7u32 + .encrypt_into_with_context(&generator, nonempty!("users/score")) + .await + .unwrap(); + assert_eq!(tagged.ob, ob); + let score: u32 = tagged + .decrypt_into(&cipher, nonempty!("users/score")) + .await + .unwrap(); + assert_eq!(score, 7); +} + +/// Two ciphertexts in one record: the type system cannot pick, so +/// `#[stash(decrypt)]` does. Only marked fields are considered, and the +/// others need not be `Decryptable` at all. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct Doubled { + #[stash(decrypt)] + c: StackCipherText, + #[stash(context = "doubled/shadow")] + shadow: StackCipherText, +} + +/// Fields that are collections or optional follow their content: a record +/// of a `Vec<u32>` has one decryptable field, its `Vec<StackCipherText>`. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = Vec<u32>)] +struct Numbers { + c: Vec<StackCipherText>, + hm: Vec<EqualityTerm>, +} + +#[tokio::test] +async fn decrypt_marks_the_field_when_the_types_cannot_choose() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let doubled: Doubled = 9u32 + .encrypt_into_with_context(&keyset, nonempty!("doubled")) + .await + .unwrap(); + let opened: u32 = doubled + .decrypt_into(&cipher, nonempty!("doubled")) + .await + .unwrap(); + assert_eq!(opened, 9); + // The unmarked ciphertext is still a ciphertext, just not the record's — + // and its literal context is extended by the caller's like any other: + // sealed under `("doubled/shadow", "doubled")`. + let doubled: Doubled = 9u32 + .encrypt_into_with_context(&keyset, nonempty!("doubled")) + .await + .unwrap(); + let shadow: u32 = doubled + .shadow + .decrypt_into( + &cipher, + nonempty!("doubled/shadow").with(nonempty!("doubled")), + ) + .await + .unwrap(); + assert_eq!(shadow, 9); + + let numbers: Numbers = vec![1u32, 2, 3] + .encrypt_into_with_context(&keyset, nonempty!("numbers")) + .await + .unwrap(); + assert_eq!(numbers.hm.len(), 3); + let opened: Vec<u32> = numbers + .decrypt_into(&cipher, nonempty!("numbers")) + .await + .unwrap(); + assert_eq!(opened, vec![1, 2, 3]); +} + +/// An index term type from outside this crate that predates `Decryptable`: +/// it implements `EncryptFrom` only, wrapping a term of ours. +#[derive(PartialEq)] +struct OpaqueTerm(EqualityTerm); + +impl<S> EncryptFrom<S> for OpaqueTerm +where + EqualityTerm: EncryptFrom<S>, +{ + type Context = <EqualityTerm as EncryptFrom<S>>::Context; + fn encryption<'s, K: 'static>() -> stack_encrypt::Encryption<'s, S, Self, K, Self::Context> + where + S: 's, + { + <EqualityTerm as EncryptFrom<S>>::encryption().map(OpaqueTerm) + } +} + +/// The documented explicit-mode shape: `#[stash(decrypt)]` frees the *other* +/// field types from `Decryptable`, so the paired derive must compile with an +/// opaque field — including the `Decryptable` impl `EncryptFrom` emits. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct WithOpaque { + #[stash(decrypt)] + c: StackCipherText, + o: OpaqueTerm, +} + +/// And the marked record is decryptable outright, so it still nests in rows. +#[allow(clippy::assertions_on_constants)] // the constant is the point +const _: () = assert!(<WithOpaque as Decryptable>::DECRYPTABLE); + +#[tokio::test] +async fn explicit_mode_supports_opaque_fields_in_the_paired_derive() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = stack_cipher().await; + let generator = generator.default_keyset(); + + let record: WithOpaque = 5u32 + .encrypt_into_with_context(&keyset, nonempty!("opaque")) + .await + .unwrap(); + let hm: EqualityTerm = 5u32 + .encrypt_into_with_context(&generator, nonempty!("opaque")) + .await + .unwrap(); + assert!(record.o == OpaqueTerm(hm)); + + let opened: u32 = record + .decrypt_into(&cipher, nonempty!("opaque")) + .await + .unwrap(); + assert_eq!(opened, 5); +} + +/// A third-party field type that breaks the `DecryptField` contract: +/// `DECRYPTABLE` says decryption opens it, but `decrypt_field` passes it +/// over anyway. +struct Lying; + +impl Decryptable for Lying { + const DECRYPTABLE: bool = true; +} + +impl<P, Ctx> DecryptField<P, Ctx> for Lying { + fn decryption_field<K: 'static>( + self, + _context: Ctx, + ) -> Option<stack_encrypt::Decryption<P, K>> { + None + } +} + +#[derive(DecryptInto)] +struct LyingRecord { + l: Lying, +} + +#[derive(Debug, PartialEq)] +struct Held { + value: u32, +} + +#[derive(DecryptInto)] +#[stash(struct = Held, context = "held")] +struct LyingHeld { + value: Lying, +} + +#[tokio::test] +async fn a_broken_decrypt_field_contract_is_not_opened_never_a_panic() { + let cipher = stack_cipher().await; + + // The compile-time check accepted `Lying` (its `DECRYPTABLE` is `true`), + // so the broken contract only shows at decrypt time: `Error::NotOpened` + // as a failed pending, for the record and for the row alike. + let result: Result<u32, _> = LyingRecord { l: Lying } + .decrypt_into(&cipher, nonempty!("l")) + .await; + assert!(matches!(result, Err(Error::NotOpened))); + + let result: Result<Held, _> = LyingHeld { value: Lying }.decrypt_into(&cipher, ()).await; + assert!(matches!(result, Err(Error::NotOpened))); +} + +/// Listed plaintexts: one impl each, and nothing else is accepted. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32, plaintext = String)] +struct EncryptedValue { + c: StackCipherText, + hm: EqualityTerm, +} + +#[tokio::test] +async fn listed_plaintexts_each_get_their_own_impl() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let number: EncryptedValue = 7u32 + .encrypt_into_with_context(&keyset, nonempty!("t/n")) + .await + .unwrap(); + let text: EncryptedValue = "seven" + .to_string() + .encrypt_into_with_context(&keyset, nonempty!("t/t")) + .await + .unwrap(); + let hm: EqualityTerm = 7u32 + .encrypt_into_with_context(&keyset, nonempty!("t/n")) + .await + .unwrap(); + assert_eq!(number.hm, hm); + + let number: u32 = number + .decrypt_into(&cipher, nonempty!("t/n")) + .await + .unwrap(); + let text: String = text.decrypt_into(&cipher, nonempty!("t/t")).await.unwrap(); + assert_eq!((number, text.as_str()), (7, "seven")); +} + +/// A plain `IntoContext` type, declared through `AeadContext`: enough to +/// seal, not to derive a term. `WorkspaceId` in `cts-common` is the +/// production shape. +#[derive(Clone, Debug, PartialEq)] +struct Tenant(String); +impl MaybeEmpty for Tenant { + fn is_empty(&self) -> bool { + self.0.is_empty() + } +} +impl<'a> IntoContext<'a> for Tenant { + fn into_context(self) -> ContextPiece<'a> { + self.0.into_context() + } +} + +/// Ciphertext only, so it declares the ciphertext operation's own context +/// type rather than the default `CallerContext`, which would demand a PRF +/// encoding no field here uses. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = String, context_type = AeadContext)] +struct SealedName { + c: StackCipherText, +} + +fn tenant() -> NonEmpty<Tenant> { + NonEmpty::new(Tenant("acme".into())).unwrap() +} + +#[tokio::test] +async fn a_ciphertext_only_record_accepts_an_aead_only_context_like_the_leaf_does() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let name = "alice".to_owned(); + + // The derived record and the canonical leaf accept the same context + // and produce interchangeable ciphertext: each opens the other's. + let record: SealedName = name + .encrypt_into_with_context(&keyset, tenant()) + .await + .unwrap(); + let opened: String = cipher.decrypt(record.c, tenant()).await.unwrap(); + assert_eq!(opened, name); + + let leaf = keyset.encrypt(name.clone(), tenant()).await.unwrap(); + let opened: String = SealedName { c: leaf } + .decrypt_into(&cipher, tenant()) + .await + .unwrap(); + assert_eq!(opened, name); + + // Bound to the context like any other leaf. + let record: SealedName = name + .encrypt_into_with_context(&keyset, tenant()) + .await + .unwrap(); + let other = NonEmpty::new(Tenant("other".into())).unwrap(); + let result: Result<String, _> = record.decrypt_into(&cipher, other).await; + assert!(matches!(result, Err(Error::Aead)), "{result:?}"); +} + +#[tokio::test] +async fn a_failed_field_fails_the_derived_record_before_any_io() { + let (cipher, generates, _) = counting_cipher().await; + let keyset = cipher.default_keyset(); + + // Text that yields no match tokens fails that leaf during the + // synchronous build; the derived record is the zip of its fields, so it + // fails the same way and never mints the data key its ciphertext field + // would have wanted. + let result: Result<SearchableText, _> = String::new() + .encrypt_into_with_context(&keyset, nonempty!("users/name")) + .await; + assert!(matches!(result, Err(Error::Term(_)))); + assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); +} + +// --- Structs: each field from one field of the plaintext, under its own context + +#[derive(Debug, Clone, PartialEq, Eq)] +struct User { + age: u32, + email: String, +} + +/// A struct encrypted field by field: every field is derived from the +/// plaintext field of its own name, under the context `"<context>/<field>"` +/// — `"user/age"`, `"user/email"` — with no attribute on the field. The +/// prefix names the stored data, explicitly: it is part of its identity, so +/// it is never inferred from the type's name. `from` is the override for a +/// field name that differs; the context then follows the plaintext field. +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, context = "user")] +struct EncryptedUser { + /// A record inside a struct: recursion, not a second mechanism. + age: EncryptedAge, + email: StackCipherText, + /// A second field from the same plaintext field — a term alongside the + /// ciphertext, not opened on decrypt. + #[stash(from = email)] + email_eq: EqualityTerm, + /// Not derived: filled in, never encrypted. + #[stash(default = 3)] + version: u8, +} + +fn user() -> User { + User { + age: 42, + email: "alice@example.com".to_string(), + } +} + +#[tokio::test] +async fn a_struct_is_one_batched_call_and_rebuilds_its_plaintext() { + let (cipher, generates, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); + let generator = stack_cipher().await; + let generator = generator.default_keyset(); + + // Every field has its own context, so the struct needs none from the + // caller: the context-free forms are the whole call, both ways. + let row: EncryptedUser = user().encrypt_into(&keyset).await.unwrap(); + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 1, + "a two-ciphertext struct must be ONE generate_keys call" + ); + assert_eq!(row.version, 3); + + // Each field's terms are what a query site derives under the field's + // inferred context: the prefix and the plaintext field's name. + let age_hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, nonempty!("user/age")) + .await + .unwrap(); + assert_eq!(row.age.hm, age_hm); + let email_hm: EqualityTerm = user() + .email + .encrypt_into_with_context(&generator, nonempty!("user/email")) + .await + .unwrap(); + assert_eq!(row.email_eq, email_hm); + + // Decryption rebuilds the plaintext field by field: one batched call. + let recovered = User::decrypt_from(row, &cipher).await.unwrap(); + assert_eq!(recovered, user()); + assert_eq!( + retrieves.load(AtomicOrdering::SeqCst), + 1, + "opening a two-ciphertext struct must be ONE retrieve_keys call" + ); +} + +#[tokio::test] +async fn a_column_of_structs_is_still_one_call_each_way() { + let (cipher, generates, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); + + let users: Vec<User> = (0..4) + .map(|i| User { + age: 30 + i, + email: format!("user{i}@example.com"), + }) + .collect(); + + let rows: Vec<EncryptedUser> = users.encrypt_into(&keyset).await.unwrap(); + assert_eq!(rows.len(), 4); + assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); + + let recovered = Vec::<User>::decrypt_from(rows, &cipher).await.unwrap(); + assert_eq!(recovered, users); + assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); +} + +#[tokio::test] +async fn a_struct_extends_its_contexts_with_the_callers() { + let (cipher, generates, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); + let generator = stack_cipher().await; + let generator = generator.default_keyset(); + + // The caller's context — the record's id — extends every inferred one: + // `age` is derived under `("user/age", 7u64)`, still in one batched + // call, and a query site probes it under the same pair. + let row: EncryptedUser = user() + .encrypt_into_with_context(&keyset, 7u64) + .await + .unwrap(); + assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); + let age_hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, nonempty!("user/age").with(7u64)) + .await + .unwrap(); + assert_eq!(row.age.hm, age_hm); + let unextended: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, nonempty!("user/age")) + .await + .unwrap(); + assert_ne!(row.age.hm, unextended); + + // Opens under the same extension, in one call — and under no other. + let recovered = User::decrypt_from_with_context(row, &cipher, 7u64) + .await + .unwrap(); + assert_eq!(recovered, user()); + assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); + + let row: EncryptedUser = user() + .encrypt_into_with_context(&keyset, 7u64) + .await + .unwrap(); + // The fake key source ignores descriptors, so the AEAD is what refuses + // a wrong context here. ZeroKMS refuses the key retrieval itself first + // (`Error::Kms`) — `examples/encrypted_record.rs` shows that live. + let other_row = User::decrypt_from_with_context(row, &cipher, 8u64).await; + assert!(matches!(other_row, Err(Error::Aead))); + let row: EncryptedUser = user() + .encrypt_into_with_context(&keyset, 7u64) + .await + .unwrap(); + let no_row = User::decrypt_from(row, &cipher).await; + assert!(matches!(no_row, Err(Error::Aead))); + + // Any context does: a string, a pair, an `Option`. + let row: EncryptedUser = user() + .encrypt_into_with_context(&keyset, nonempty!("tenant/acme")) + .await + .unwrap(); + let recovered = User::decrypt_from_with_context(row, &cipher, nonempty!("tenant/acme")) + .await + .unwrap(); + assert_eq!(recovered, user()); +} + +#[tokio::test] +async fn a_struct_field_opened_under_the_wrong_context_fails() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let row: EncryptedUser = user().encrypt_into(&keyset).await.unwrap(); + // The literal contexts are baked into the impl, so a transplanted field + // is caught exactly as for a leaf: by the AAD against the fake key + // source, by ZeroKMS's descriptor check (`Error::Kms`) before that in + // production. + let transplanted: Result<u32, _> = row + .age + .c + .decrypt_into(&cipher, nonempty!("user/height")) + .await; + assert!(matches!(transplanted, Err(Error::Aead))); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Account { + user: User, + plan: String, +} + +/// A struct nesting a struct: `#[stash(nested)]` opts the field out of the +/// inferred context — the inner struct carries its own — so it is handed +/// the caller's context as it is, which the inner struct composes with them. +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = Account, context = "accounts")] +struct EncryptedAccount { + #[stash(nested)] + user: EncryptedUser, + plan: StackCipherText, +} + +#[tokio::test] +async fn a_struct_nests_in_a_struct_via_nested() { + let (cipher, generates, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); + let generator = stack_cipher().await; + let generator = generator.default_keyset(); + + let account = Account { + user: user(), + plan: "pro".to_string(), + }; + let row: EncryptedAccount = account.encrypt_into(&keyset).await.unwrap(); + assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); + + // The inner struct's fields are still under their own contexts. + let age_hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, nonempty!("user/age")) + .await + .unwrap(); + assert_eq!(row.user.age.hm, age_hm); + + let recovered = Account::decrypt_from(row, &cipher).await.unwrap(); + assert_eq!(recovered, account); + assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); + + // The outer's plan is under the inferred `"accounts/plan"`. Decrypting + // the row consumed it, so mint a fresh one to open the field alone. + let row: EncryptedAccount = account.encrypt_into(&keyset).await.unwrap(); + let plan: String = row + .plan + .decrypt_into(&cipher, nonempty!("accounts/plan")) + .await + .unwrap(); + assert_eq!(plan, "pro"); + + // An extension reaches the nested struct unchanged and is composed with + // its own contexts there: the inner `age` is under `("user/age", id)`, + // the outer `plan` under `("accounts/plan", id)`. + let row: EncryptedAccount = account + .encrypt_into_with_context(&keyset, 9u64) + .await + .unwrap(); + let age_hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, nonempty!("user/age").with(9u64)) + .await + .unwrap(); + assert_eq!(row.user.age.hm, age_hm); + let plan: String = row + .plan + .decrypt_into(&cipher, nonempty!("accounts/plan").with(9u64)) + .await + .unwrap(); + assert_eq!(plan, "pro"); +} + +/// A tuple-struct plaintext is reached by index — inferred for a tuple +/// struct, `from = 0` when the encrypted struct has named fields — and named +/// by it in the context: `"reading/0"`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Reading(u32, String); + +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = Reading, context = "reading")] +struct EncryptedReading(EncryptedAge, StackCipherText); + +/// The same with named fields: `from` by index, and the context follows the +/// index too unless given. +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = Reading, context = "reading")] +struct NamedReading { + #[stash(from = 0)] + value: EncryptedAge, + #[stash(from = 1, context = "readings/unit")] + unit: StackCipherText, +} + +#[tokio::test] +async fn a_tuple_plaintext_is_reached_and_rebuilt_by_index() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = stack_cipher().await; + let generator = generator.default_keyset(); + + let reading = Reading(21, "celsius".into()); + let row: EncryptedReading = reading.encrypt_into(&keyset).await.unwrap(); + + let hm: EqualityTerm = 21u32 + .encrypt_into_with_context(&generator, nonempty!("reading/0")) + .await + .unwrap(); + assert_eq!(row.0.hm, hm); + + let recovered = Reading::decrypt_from(row, &cipher).await.unwrap(); + assert_eq!(recovered, reading); + + let named: NamedReading = reading.encrypt_into(&keyset).await.unwrap(); + assert_eq!(named.value.hm, hm, "from = 0 infers the same context"); + let unit: String = named + .unit + .decrypt_into(&cipher, nonempty!("readings/unit")) + .await + .unwrap(); + assert_eq!(unit, "celsius"); +} + +// --- A field handed a context converts it into what its type declares ------- + +/// A record with a context of its own wrapping a struct record that carries +/// its own: the literal gives the whole subtree its context, and the inner +/// record's own contexts are extended by it — `("user/age", "wrapped")`. +/// The derive is told nothing about the inner record's context type. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = User)] +struct WrappedUser { + #[stash(context = "wrapped")] + user: EncryptedUser, +} + +#[tokio::test] +async fn a_field_with_its_own_context_may_be_a_record_with_declared_contexts() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = stack_cipher().await; + let generator = generator.default_keyset(); + + let row: WrappedUser = user().encrypt_into(&keyset).await.unwrap(); + let age_hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, nonempty!("user/age").with(nonempty!("wrapped"))) + .await + .unwrap(); + assert_eq!(row.user.age.hm, age_hm); + + let recovered = User::decrypt_from(row, &cipher).await.unwrap(); + assert_eq!(recovered, user()); +} + +/// A record whose one field pins a literal context: needs nothing from the +/// caller, and a caller's context extends the literal. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct PinnedAge { + #[stash(context = "legacy/age")] + c: StackCipherText, +} + +/// A record mixing a leaf with a context of its own and a bare record whose +/// fields declare theirs: the caller's context is required, since the bare +/// field needs it; the leaf's literal is extended by it; and the record is +/// handed it as it is and composes it with its own. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct AuditedAge { + #[stash(context = "audit/age", decrypt)] + audit: StackCipherText, + age: PinnedAge, +} + +#[tokio::test] +async fn a_bare_record_field_takes_the_callers_context_beside_a_leaf_with_its_own() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let row: AuditedAge = 42u32 + .encrypt_into_with_context(&keyset, nonempty!("tenant")) + .await + .unwrap(); + // The leaf: its own literal, extended by the caller's. + let audit: u32 = row + .audit + .decrypt_into(&cipher, nonempty!("audit/age").with(nonempty!("tenant"))) + .await + .unwrap(); + assert_eq!(audit, 42); + // The record: handed the caller's as it is, which extends its own. + let age: u32 = row + .age + .c + .decrypt_into(&cipher, nonempty!("legacy/age").with(nonempty!("tenant"))) + .await + .unwrap(); + assert_eq!(age, 42); + + // And the record as a whole opens under the caller's context. + let row: AuditedAge = 42u32 + .encrypt_into_with_context(&keyset, nonempty!("tenant")) + .await + .unwrap(); + let opened: u32 = row + .decrypt_into(&cipher, nonempty!("tenant")) + .await + .unwrap(); + assert_eq!(opened, 42); +} diff --git a/packages/stack-encrypt/tests/descriptor.rs b/packages/stack-encrypt/tests/descriptor.rs new file mode 100644 index 000000000..a59057e13 --- /dev/null +++ b/packages/stack-encrypt/tests/descriptor.rs @@ -0,0 +1,285 @@ +//! What reaches ZeroKMS: every data-key request carries the requesting +//! context as its descriptor, on generate and on retrieve alike. +//! +//! The fake source ignores descriptors (see `FakeDataKeySource`'s docs), so +//! these tests assert what is *sent*. The real service HMACs the descriptor +//! into the key tag and refuses to re-derive under a different one — the +//! examples exercise that against a live ZeroKMS. + +mod common; + +use common::recording_cipher; +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptFrom, EncryptInto}; +use stack_encrypt::{nonempty, DecryptInto, Descriptor, EncryptFrom, Error, StackCipherText}; + +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct EncryptedAge { + c: StackCipherText, +} + +#[derive(Debug, PartialEq, Clone)] +struct User { + email: String, + name: String, + age: u32, +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + email: StackCipherText, + #[stash(from = email)] + email_hm: EqualityTerm, + #[stash(context = "people/name")] + name: StackCipherText, + age: EncryptedAge, +} + +fn user() -> User { + User { + email: "alice@example.com".into(), + name: "Alice".into(), + age: 34, + } +} + +#[tokio::test] +async fn a_leaf_sends_its_context_as_the_descriptor_both_ways() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); + + let ct: StackCipherText = "alice" + .encrypt_into_with_context(&keyset, nonempty!("users/email")) + .await?; + let _: String = ct.decrypt_into(&cipher, nonempty!("users/email")).await?; + + let sent = sent.lock().expect("lock").clone(); + assert_eq!(sent.generated(), ["users/email"]); + assert_eq!(sent.retrieved(), ["users/email"]); + Ok(()) +} + +#[tokio::test] +async fn a_struct_sends_one_descriptor_per_field_context() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); + + let row: EncryptedUser = user().encrypt_into(&keyset).await?; + let back = User::decrypt_from(row, &cipher).await?; + assert_eq!(back, user()); + + // Inferred `users/email`, the field's own `people/name`, and the inner + // record under `users/age`; the term derives no key. One call each way. + let sent = sent.lock().expect("lock").clone(); + assert_eq!(sent.generate.len(), 1, "one generate_keys call"); + assert_eq!(sent.retrieve.len(), 1, "one retrieve_keys call"); + assert_eq!( + sent.generated(), + ["users/email", "people/name", "users/age"] + ); + assert_eq!( + sent.retrieved(), + ["users/email", "people/name", "users/age"] + ); + Ok(()) +} + +#[tokio::test] +async fn a_callers_context_extends_every_fields_descriptor() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); + + let row: EncryptedUser = user().encrypt_into_with_context(&keyset, 7u64).await?; + let back = User::decrypt_from_with_context(row, &cipher, 7u64).await?; + assert_eq!(back, user()); + + // The extended contexts are composites, rendered part by part — the + // same value the leaf AAD and the term context are built from. + let expected: Vec<String> = [ + Descriptor::of(nonempty!("users/email").with(7u64)), + Descriptor::of(nonempty!("people/name").with(7u64)), + Descriptor::of(nonempty!("users/age").with(7u64)), + ] + .iter() + .map(|d| d.as_str().to_owned()) + .collect(); + assert_eq!( + expected, + ["users/email|7u64", "people/name|7u64", "users/age|7u64"], + "a composite context renders readably" + ); + let sent = sent.lock().expect("lock").clone(); + assert_eq!(sent.generated(), expected); + assert_eq!(sent.retrieved(), expected); + Ok(()) +} + +#[tokio::test] +async fn every_leaf_of_a_tree_shares_the_root_descriptor() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); + + let column: Vec<StackCipherText> = vec![1u32, 2, 3] + .encrypt_into_with_context(&keyset, nonempty!("users/age")) + .await?; + let _: Vec<u32> = column.decrypt_into(&cipher, nonempty!("users/age")).await?; + + // Per-element AAD derivation is vitaminc's and stays inside the AEAD; + // ZeroKMS sees the field, not the element. + let sent = sent.lock().expect("lock").clone(); + assert_eq!(sent.generated(), ["users/age"; 3]); + assert_eq!(sent.retrieved(), ["users/age"; 3]); + Ok(()) +} + +#[tokio::test] +async fn the_cipher_directed_path_renders_its_aad_the_same_way() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); + + let ct = keyset.encrypt(42u32, "users/age").await?; + let _: u32 = cipher.decrypt(ct, "users/age").await?; + // No AAD at all is the empty descriptor: ZeroKMS binds nothing. + let ct = keyset.encrypt(42u32, ()).await?; + let _: u32 = cipher.decrypt(ct, ()).await?; + + let sent = sent.lock().expect("lock").clone(); + assert_eq!(sent.generated(), ["users/age", ""]); + assert_eq!(sent.retrieved(), ["users/age", ""]); + Ok(()) +} + +/// A column renders its context once to check it, then refuses the whole +/// column: an over-long context under ten thousand elements is one +/// rendering, not ten thousand, on either path. +#[tokio::test] +async fn a_column_renders_an_over_long_context_once() -> Result<(), Error> { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + /// A context that counts how often it is encoded. Over the limit once + /// rendered, so every path refuses it. + #[derive(Clone)] + struct Counted(Arc<AtomicUsize>); + + impl<'a> stack_encrypt::IntoContext<'a> for Counted { + fn into_context(self) -> stack_encrypt::ContextPiece<'a> { + self.0.fetch_add(1, Ordering::SeqCst); + stack_encrypt::ContextPiece::Text("a".repeat(Descriptor::MAX_LEN + 1).into()) + } + } + impl stack_encrypt::MaybeEmpty for Counted { + fn is_empty(&self) -> bool { + false + } + } + + let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); + let renders = Arc::new(AtomicUsize::new(0)); + let context = stack_encrypt::NonEmpty::new(Counted(renders.clone())).unwrap(); + + let values: Vec<u32> = (0..10_000).collect(); + let result: Result<Vec<StackCipherText>, Error> = values + .encrypt_into_with_context(&keyset, context.clone()) + .await; + assert!( + matches!(result, Err(Error::DescriptorTooLong { .. })), + "{result:?}" + ); + assert_eq!( + renders.load(Ordering::SeqCst), + 1, + "one rendering on encrypt" + ); + + let column: Vec<StackCipherText> = vec![1u32, 2, 3] + .encrypt_into_with_context(&keyset, nonempty!("users/age")) + .await?; + let opened: Result<Vec<u32>, Error> = column.decrypt_into(&cipher, context).await; + assert!( + matches!(opened, Err(Error::DescriptorTooLong { .. })), + "{opened:?}" + ); + assert_eq!( + renders.load(Ordering::SeqCst), + 2, + "one rendering on decrypt" + ); + + let sent = sent.lock().expect("lock").clone(); + assert_eq!( + sent.generated(), + ["users/age"; 3], + "only the good seal was sent" + ); + assert!(sent.retrieved().is_empty(), "nothing retrieved"); + Ok(()) +} + +/// The column check is for columns that bind keys. A column of terms +/// derives locally under any context, however long, and an empty column +/// binds nothing — neither is held to the descriptor limit. +#[tokio::test] +async fn a_column_with_nothing_to_bind_takes_any_context() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); + let long = stack_encrypt::NonEmpty::new("a".repeat(Descriptor::MAX_LEN + 1)).unwrap(); + + let names = vec!["alice".to_string(), "bob".to_string()]; + let terms: Vec<EqualityTerm> = names + .encrypt_into_with_context(&keyset, long.clone()) + .await?; + assert_eq!(terms.len(), 2); + + let none: Vec<u32> = Vec::new(); + let sealed: Vec<StackCipherText> = none + .encrypt_into_with_context(&keyset, long.clone()) + .await?; + assert!(sealed.is_empty()); + let opened: Vec<u32> = sealed.decrypt_into(&cipher, long).await?; + assert!(opened.is_empty()); + + let sent = sent.lock().expect("lock").clone(); + assert!(sent.generated().is_empty() && sent.retrieved().is_empty()); + Ok(()) +} + +/// A context that renders past ZeroKMS's descriptor limit is refused before +/// a single request is built — not after one per leaf — and nothing is sent: +/// the size of the tree does not multiply the cost of an over-long context. +#[tokio::test] +async fn an_over_long_context_is_refused_before_any_request_on_either_path() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); + let long = stack_encrypt::NonEmpty::new("a".repeat(Descriptor::MAX_LEN + 1)).unwrap(); + + let values: Vec<u32> = (0..10_000).collect(); + let result: Result<Vec<StackCipherText>, Error> = values + .encrypt_into_with_context(&keyset, long.clone()) + .await; + assert!( + matches!(result, Err(Error::DescriptorTooLong { len }) if len == Descriptor::MAX_LEN + 1), + "{result:?}" + ); + + let sealed: StackCipherText = 7u32 + .encrypt_into_with_context(&keyset, nonempty!("users/age")) + .await?; + let opened: Result<u32, Error> = sealed.decrypt_into(&cipher, long).await; + assert!( + matches!(opened, Err(Error::DescriptorTooLong { .. })), + "{opened:?}" + ); + + let sent = sent.lock().expect("lock").clone(); + assert_eq!( + sent.generated(), + ["users/age"], + "only the good seal was sent" + ); + assert!(sent.retrieved().is_empty(), "nothing retrieved"); + Ok(()) +} diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs new file mode 100644 index 000000000..b5833b27b --- /dev/null +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -0,0 +1,563 @@ +//! Byte-level pins for the frozen encodings stack-encrypt commits to across +//! languages: +//! +//! * the [`SealedValue`] leaf layout +//! (`version ‖ keyset_id ‖ iv ‖ tag_len ‖ tag ‖ local_ciphertext`) — the +//! storage +//! format a database column holds, and +//! * the index-term encodings (equality: raw 32 bytes; match: LE `u16` +//! positions; ORE/OPE: raw CLLW ciphertext bytes). +//! +//! These are the vectors a language binding's decoder tests against — the +//! Go side decodes exactly these hex strings. `tests/term_bytes.rs` pins the +//! *derivations* (PRF domains and framing); this file pins the *encodings* +//! of the results. Breaking a pin here breaks a consumer: for the leaf it +//! moves the storage format and demands a `SealedValue::FORMAT_VERSION` bump; +//! for the equality and ORE/OPE terms it moves the bytes a column holds; for +//! the match term it moves the wasm/FFI transport shape (no column holds +//! that byte string — the stored and queried contract is the position list, +//! which maps to an integer-array column), and every binding decoding it +//! silently stops agreeing. + +use std::borrow::Cow; + +use stack_encrypt::nonempty; +use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerm, OpeTerm, OreTerm, TermBytesError}; +use stack_encrypt::target::EncryptInto; +use stack_encrypt::{CipherText, Error, LeafBytesError, SealedValue, StackCipher}; +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, + IndexKey, IndexKeySource, RetrieveKeyPayload, UnverifiedContext, +}; +use uuid::Uuid; + +async fn cipher() -> StackCipher<FakeDataKeySource> { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher") +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +// ============================================================================= +// SealedValue leaf +// ============================================================================= + +/// A leaf from fixed parts, so the encoding is deterministic. The +/// "ciphertext" is not a real AEAD output — encoding is structural and must +/// not care. +fn fixture_leaf() -> SealedValue { + let keyset_id = Uuid::from_bytes(*b"keyset-fixture16"); + let iv: stack_kms::Iv = *b"0123456789abcdef"; + SealedValue::from_parts( + keyset_id, + iv, + vec![0xAA, 0xBB, 0xCC], + vec![0xDE, 0xAD, 0xBE, 0xEF], + ) + .expect("fixture tag fits the length field") +} + +#[test] +fn sealed_value_layout_is_pinned() { + let bytes = fixture_leaf().to_bytes(); + + // version(01) ‖ keyset_id(16 raw UUID bytes: ASCII "keyset-fixture16") ‖ + // iv(16 bytes: ASCII "0123456789abcdef") ‖ tag_len(0300 — 3, u16 LE) ‖ + // tag(aabbcc) ‖ local_ciphertext(deadbeef) + assert_eq!( + hex(&bytes), + "016b65797365742d666978747572653136303132333435363738396162636465660300aabbccdeadbeef" + ); +} + +#[test] +fn sealed_value_from_bytes_inverts_to_bytes() { + let original = fixture_leaf(); + let bytes = original.to_bytes(); + let decoded = SealedValue::from_bytes(&bytes).expect("decode leaf"); + + assert_eq!(decoded.keyset_id(), original.keyset_id()); + assert_eq!(decoded.iv(), original.iv()); + assert_eq!(decoded.tag(), original.tag()); + assert_eq!(decoded.ciphertext(), original.ciphertext()); + + // The std conversion is the same decoder. + let converted = SealedValue::try_from(bytes.as_slice()).expect("TryFrom decode"); + assert_eq!(converted.keyset_id(), original.keyset_id()); + assert_eq!(converted.ciphertext(), original.ciphertext()); +} + +#[test] +fn sealed_value_rejects_unknown_version() { + let mut bytes = fixture_leaf().to_bytes(); + bytes[0] = 2; + assert!(matches!( + SealedValue::from_bytes(&bytes), + Err(LeafBytesError::UnknownVersion(2)) + )); +} + +#[test] +fn sealed_value_rejects_truncation() { + let bytes = fixture_leaf().to_bytes(); + + // Every prefix shorter than the tag's end is truncated: empty, + // mid-keyset-id, mid-iv, mid-length-field, and mid-tag. (Anything at or + // past the tag's end parses — the local ciphertext takes the remainder, + // and proving *it* whole is the AEAD open's job.) + let tag_end = 1 + 16 + 16 + 2 + 3; + for len in 0..tag_end { + assert!( + matches!( + SealedValue::from_bytes(&bytes[..len]), + Err(LeafBytesError::Truncated) + ), + "prefix of {len} bytes must be rejected" + ); + } + assert!(SealedValue::from_bytes(&bytes[..tag_end]).is_ok()); +} + +#[test] +fn sealed_value_decodes_the_shortest_leaf_the_layout_allows() { + // An empty tag and an empty ciphertext leave exactly the fixed-width + // fields: version ‖ keyset_id ‖ iv ‖ tag_len. That is a whole leaf — + // structurally, whatever the AEAD makes of it — and one byte less is + // truncated. With a non-empty tag the tag check would reject a short + // buffer anyway, so only this shape pins the fixed-width check itself. + let leaf = SealedValue::from_parts(Uuid::nil(), [7; 16], Vec::new(), Vec::new()) + .expect("an empty tag fits"); + let bytes = leaf.to_bytes(); + assert_eq!( + bytes.len(), + 1 + 16 + 16 + 2, + "an empty tag and ciphertext should leave only the fixed-width fields" + ); + + let decoded = SealedValue::from_bytes(&bytes).expect("the fixed fields alone are a leaf"); + assert_eq!( + decoded.keyset_id(), + Uuid::nil(), + "the keyset id should survive the round trip" + ); + assert_eq!( + decoded.iv(), + &[7; 16], + "the iv should survive the round trip" + ); + assert!(decoded.tag().is_empty(), "the tag should decode as empty"); + assert!( + decoded.ciphertext().is_empty(), + "the ciphertext should decode as empty" + ); + + assert!( + matches!( + SealedValue::from_bytes(&bytes[..bytes.len() - 1]), + Err(LeafBytesError::Truncated) + ), + "one byte short of the fixed-width fields should be truncated" + ); +} + +#[test] +fn sealed_value_accepts_the_longest_tag_the_length_field_frames() { + // `u16::MAX` bytes is the last tag the length field can state, so it is + // a valid leaf — and it must survive the byte format intact. + let tag = vec![0x5A; usize::from(u16::MAX)]; + let leaf = SealedValue::from_parts(Uuid::nil(), [0; 16], tag.clone(), vec![0xDE, 0xAD]) + .expect("a u16::MAX-byte tag fits the length field"); + + let decoded = SealedValue::from_bytes(&leaf.to_bytes()).expect("decode leaf"); + assert_eq!( + decoded.tag(), + tag.as_slice(), + "a u16::MAX-byte tag should survive the round trip intact" + ); + assert_eq!( + decoded.ciphertext(), + [0xDE, 0xAD].as_slice(), + "the ciphertext after the longest tag should still be framed correctly" + ); +} + +#[test] +fn sealed_value_rejects_oversized_tag_on_construction() { + // `to_bytes` is infallible because the tag can never outgrow the `u16` + // length field: the only constructor that could admit one rejects it. + let result = SealedValue::from_parts( + Uuid::nil(), + [0; 16], + vec![0; usize::from(u16::MAX) + 1], + vec![0xDE, 0xAD], + ); + assert!(matches!( + result, + Err(LeafBytesError::TagTooLong(len)) if len == usize::from(u16::MAX) + 1 + )); +} + +#[tokio::test] +async fn sealed_leaf_survives_persistence_via_bytes() { + // The format round-trips a *real* leaf: encrypt, encode, decode, decrypt. + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt("durable".to_string(), b"ctx".as_slice()) + .await + .expect("encrypt"); + let leaf = match ct { + CipherText::Single(leaf) => leaf, + other => panic!("expected a Single leaf, got {other:?}"), + }; + let bytes = leaf.to_bytes(); + let restored = SealedValue::from_bytes(&bytes).expect("decode leaf"); + + let pt: String = cipher + .decrypt(CipherText::Single(restored), b"ctx".as_slice()) + .await + .expect("decoded leaf must decrypt"); + assert_eq!(pt, "durable"); +} + +#[tokio::test] +async fn sealed_value_keyset_id_is_authenticated() { + // The keyset id is bound into the leaf's AAD: a leaf re-pointed at another + // keyset fails to open. (The fake source ignores keyset ids, so the key + // retrieve itself succeeds — the AEAD is what refuses.) + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let aad = b"ctx".as_slice(); + let ct = keyset + .encrypt("durable".to_string(), aad) + .await + .expect("encrypt"); + let leaf = match ct { + CipherText::Single(leaf) => leaf, + other => panic!("expected a Single leaf, got {other:?}"), + }; + let (keyset_id, iv, tag, bytes) = leaf.into_parts(); + let other_keyset = Uuid::from_bytes(*b"another-keyset16"); + assert_ne!(keyset_id, other_keyset); + let tampered = SealedValue::from_parts(other_keyset, iv, tag, bytes).expect("rebuild leaf"); + + let result = cipher + .decrypt::<String, _>(CipherText::Single(tampered), aad) + .await; + assert!( + matches!(result, Err(Error::Aead)), + "a leaf re-pointed at another keyset must not decrypt: {result:?}" + ); +} + +/// Delegates to [`FakeDataKeySource`] but inflates every generated key tag +/// past the `u16` length field — the misbehaving custom [`DataKeySource`] the +/// seal path must reject, rather than build a leaf whose `to_bytes` writes a +/// saturated length field that `from_bytes` no longer inverts. +struct OversizedTagSource(FakeDataKeySource); + +impl DataKeySource for OversizedTagSource { + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, stack_kms::Error> { + let mut keys = self + .0 + .generate_keys(payloads, keyset_id, unverified_context) + .await?; + for key in &mut keys { + key.tag = vec![0; usize::from(u16::MAX) + 1]; + } + Ok(keys) + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, stack_kms::Error> { + self.0 + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } +} + +impl IndexKeySource for OversizedTagSource { + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.0.load_index_key(keyset_id).await + } +} + +#[tokio::test] +async fn seal_rejects_a_key_tag_the_length_field_cannot_frame() { + let cipher = StackCipher::builder() + .kms(OversizedTagSource(FakeDataKeySource::new())) + .init() + .await + .expect("build cipher"); + + let result = cipher + .default_keyset() + .encrypt("boundary".to_string(), b"ctx".as_slice()) + .await; + assert!( + matches!(result, Err(Error::Aead)), + "an oversized key tag must fail the seal, not mis-encode: {result:?}" + ); +} + +// ============================================================================= +// Terms +// ============================================================================= + +#[tokio::test] +async fn equality_term_encoding_is_the_raw_prf_bytes() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let term = keyset + .equality_term("alice", nonempty!("users/email")) + .await + .expect("equality term"); + + // The derivation is pinned in term_bytes.rs; here: encoding = identity + // over those 32 bytes, and from_bytes is its inverse. + assert_eq!(term.as_ref(), term.as_bytes()); + assert_eq!(term.to_bytes(), term.as_bytes()); + assert_eq!(EqualityTerm::from_bytes(*term.as_bytes()), term); + + // The std conversion is the same decoder, over a slice of unknown length. + assert_eq!( + EqualityTerm::try_from(term.to_bytes().as_slice()).expect("TryFrom decode"), + term + ); + // And the owned conversion out is the same encoding. + let bytes = term.to_bytes(); + assert_eq!( + Vec::<u8>::from(term), + bytes, + "the owned conversion should produce the same encoding as to_bytes" + ); +} + +#[test] +fn equality_term_try_from_rejects_wrong_length() { + assert_eq!( + EqualityTerm::try_from([0u8; 31].as_slice()), + Err(TermBytesError::WrongEqualityTermLength(31)) + ); + assert_eq!( + EqualityTerm::try_from([0u8; 33].as_slice()), + Err(TermBytesError::WrongEqualityTermLength(33)) + ); +} + +#[tokio::test] +async fn match_term_bytes_are_pinned() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let term = keyset + .match_terms::<DefaultMatch>("alice smith", nonempty!("users/name")) + .await + .expect("match term"); + + // The little-endian u16 encoding of the positions pinned in + // term_bytes.rs, in sorted order. + let bytes = term.to_bytes(); + assert_eq!( + hex(&bytes), + "040005000a000d000e001e002700350038003d0055005e005f0064006f007d007f009c00ad00bc00bd00ca00d000e000e500ef00" + ); + assert_eq!( + MatchTerm::<DefaultMatch>::from_bytes(&bytes).expect("decode match term"), + term + ); + // The std conversion is the same decoder. + assert_eq!( + MatchTerm::<DefaultMatch>::try_from(bytes.as_slice()).expect("TryFrom decode"), + term + ); +} + +/// `Debug` shows the positions — the stored, queried form — and nothing +/// else. +#[test] +fn match_term_debug_is_its_positions() { + let term = MatchTerm::<DefaultMatch>::from_positions(vec![17, 3]).expect("in range"); + assert_eq!( + format!("{term:?}"), + "MatchTerm { positions: [3, 17] }", + "Debug should show only the sorted positions" + ); +} + +#[test] +fn match_term_from_bytes_rejects_odd_length() { + assert_eq!( + MatchTerm::<DefaultMatch>::from_bytes(&[0x21]), + Err(TermBytesError::OddMatchTermLength(1)) + ); +} + +#[test] +fn match_term_from_bytes_rejects_positions_outside_the_filter() { + // `DefaultMatch` is a 256-bit filter, so genuine positions are 0..256 and + // the high byte of every LE u16 is zero. A position at the filter size, + // and the 0xffff a wrong-endian decoder produces, are both rejected — + // they would otherwise decode cleanly and then silently never match. + assert_eq!( + MatchTerm::<DefaultMatch>::from_bytes(&[0x00, 0x01]), + Err(TermBytesError::MatchPositionOutOfRange { + position: 256, + filter_size: 256, + }) + ); + assert_eq!( + MatchTerm::<DefaultMatch>::from_bytes(&[0xff, 0xff]), + Err(TermBytesError::MatchPositionOutOfRange { + position: 0xffff, + filter_size: 256, + }) + ); + // Byte-swapping a genuine term is exactly that failure: position 0x21 + // becomes 0x2100. + assert!(matches!( + MatchTerm::<DefaultMatch>::from_bytes(&[0x00, 0x21]), + Err(TermBytesError::MatchPositionOutOfRange { .. }) + )); + + // In-range positions round-trip, through both constructors. + let positions = vec![0u16, 1, 255]; + let term = MatchTerm::<DefaultMatch>::from_positions(positions.clone()).expect("in range"); + assert_eq!(term.positions(), positions.as_slice()); + assert_eq!( + MatchTerm::<DefaultMatch>::from_bytes(&term.to_bytes()).expect("decode"), + term + ); + assert_eq!( + MatchTerm::<DefaultMatch>::from_positions(vec![256]), + Err(TermBytesError::MatchPositionOutOfRange { + position: 256, + filter_size: 256, + }) + ); +} + +#[tokio::test] +async fn ore_term_encoding_is_the_raw_cllw_bytes() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let term: OreTerm<u32> = 42u32 + .encrypt_into_with_context(&keyset, nonempty!("users/age")) + .await + .expect("ore term"); + + // Byte-identical to the raw CLLW output pinned in term_bytes.rs — the + // wrapper adds no framing. Same *shape* as the CLLW bytes EQL stores, but + // not comparable with rows cipherstash-client wrote: the key derivations + // differ (see the `sem` module docs). + assert_eq!( + hex(term.as_bytes()), + "1ae5f8558dc2d7dddd6c5b714e9d285586a1b8390d9140421e78906cba1bd651" + ); + assert_eq!(term.to_bytes(), term.as_bytes()); + assert_eq!(term.as_ref(), term.as_bytes()); + assert_eq!( + OreTerm::<u32>::from_bytes(term.as_bytes()).expect("decode ore term"), + term + ); + // The std conversion is the same decoder. + assert_eq!( + OreTerm::<u32>::try_from(term.as_bytes()).expect("TryFrom decode"), + term + ); +} + +#[tokio::test] +async fn ope_term_encoding_is_the_raw_cllw_bytes() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let term: OpeTerm<u32> = 42u32 + .encrypt_into_with_context(&keyset, nonempty!("users/age")) + .await + .expect("ope term"); + + assert_eq!( + hex(term.as_bytes()), + "00837615a1ea2fdcbebf7efe34cf4d2ee432c7eeff84fbd72e1bf05efa2338033c" + ); + assert_eq!( + OpeTerm::<u32>::from_bytes(term.as_bytes()).expect("decode ope term"), + term + ); + assert_eq!( + OpeTerm::<u32>::try_from(term.as_bytes()).expect("TryFrom decode"), + term + ); +} + +#[test] +fn ore_term_from_bytes_rejects_wrong_length() { + // u32 → OreCllw8V1<32>: exactly 32 bytes. + assert_eq!( + OreTerm::<u32>::from_bytes(&[0u8; 31]), + Err(TermBytesError::MalformedCllwCiphertext(31)) + ); + assert_eq!( + OreTerm::<u32>::from_bytes(&[0u8; 33]), + Err(TermBytesError::MalformedCllwCiphertext(33)) + ); + // u32 → OpeCllw8V1<33>: exactly 33 bytes. + assert_eq!( + OpeTerm::<u32>::from_bytes(&[0u8; 32]), + Err(TermBytesError::MalformedCllwCiphertext(32)) + ); +} + +#[tokio::test] +async fn variable_length_ore_and_ope_terms_decode() { + // String sources produce variable-length CLLW ciphertexts (8 bytes per + // plaintext byte; OPE adds a leading carry byte) — their decode path is + // the length-validating TryFrom in cllw-ore. + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ore: OreTerm<String> = "alice" + .to_string() + .encrypt_into_with_context(&keyset, nonempty!("users/name")) + .await + .expect("ore term"); + assert_eq!(ore.as_bytes().len(), 5 * 8); + assert_eq!( + OreTerm::<String>::from_bytes(ore.as_bytes()).expect("decode"), + ore + ); + assert_eq!( + OreTerm::<String>::from_bytes(&ore.as_bytes()[1..]), + Err(TermBytesError::MalformedCllwCiphertext(5 * 8 - 1)) + ); + + let ope: OpeTerm<String> = "alice" + .to_string() + .encrypt_into_with_context(&keyset, nonempty!("users/name")) + .await + .expect("ope term"); + assert_eq!(ope.as_bytes().len(), 5 * 8 + 1); + assert_eq!( + OpeTerm::<String>::from_bytes(ope.as_bytes()).expect("decode"), + ope + ); + assert_eq!( + OpeTerm::<String>::from_bytes(&ope.as_bytes()[1..]), + Err(TermBytesError::MalformedCllwCiphertext(5 * 8)) + ); +} diff --git a/packages/stack-encrypt/tests/keysets.rs b/packages/stack-encrypt/tests/keysets.rs new file mode 100644 index 000000000..f36238188 --- /dev/null +++ b/packages/stack-encrypt/tests/keysets.rs @@ -0,0 +1,714 @@ +//! Keysets: one client, many keysets. Selection, caching, and the +//! keyset-scoped versus client-scoped decrypt paths. + +use stack_encrypt::DecryptFrom; +use std::borrow::Cow; +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use stack_encrypt::target::EncryptInto; +use stack_encrypt::{nonempty, CipherText, Error, SealedValue, StackCipher, StackCipherText}; +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, + IndexKey, IndexKeySource, LoadKeysetError, RetrieveKeyPayload, UnverifiedContext, +}; +use uuid::Uuid; +use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; + +/// The fake, plus a count of keyset loads and a log of the keyset each +/// retrieve call named — the two facts the cache and the grouped dispatch +/// are about — and two knobs for the name-lookup races: a name can be +/// *refused* (ZeroKMS answers the lookup with an error) and the next lookup +/// of a name can be *held* until released, so an answer can be in flight +/// while a later lookup completes. +#[derive(Default)] +struct Observed { + inner: FakeDataKeySource, + loads: AtomicUsize, + retrieve_keysets: Mutex<Vec<Option<Uuid>>>, + refused: Mutex<HashMap<String, Refusal>>, + /// The name whose *next* lookup waits for [`release`](Self::release). + held: Mutex<Option<String>>, + released: AtomicBool, +} + +/// How a refused name's lookup fails: with ZeroKMS's own answer that no +/// keyset has the name, or with no answer at all. +#[derive(Clone, Copy)] +enum Refusal { + Unknown, + Unreachable, +} + +impl Observed { + fn loads(&self) -> usize { + self.loads.load(Ordering::Relaxed) + } + + fn retrieve_keysets(&self) -> Vec<Option<Uuid>> { + self.retrieve_keysets.lock().expect("lock").clone() + } + + /// Every lookup of `name` from now on fails as `how` says. + fn refuse(&self, name: &str, how: Refusal) { + let _ = self + .refused + .lock() + .expect("lock") + .insert(name.to_owned(), how); + } + + fn allow(&self, name: &str) { + let _ = self.refused.lock().expect("lock").remove(name); + } + + /// The next lookup of `name` decides its answer on arrival but does not + /// return it until [`release`](Self::release). + fn hold(&self, name: &str) { + *self.held.lock().expect("lock") = Some(name.to_owned()); + self.released.store(false, Ordering::SeqCst); + } + + fn release(&self) { + self.released.store(true, Ordering::SeqCst); + } +} + +impl DataKeySource for Observed { + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, stack_kms::Error> { + self.inner + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, stack_kms::Error> { + self.retrieve_keysets.lock().expect("lock").push(keyset_id); + self.inner + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } +} + +impl IndexKeySource for Observed { + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.loads.fetch_add(1, Ordering::Relaxed); + let asked = match &keyset_id { + Some(IdentifiedBy::Name(name)) => Some(name.to_string()), + Some(IdentifiedBy::Uuid(_)) | None => None, + }; + // The answer is decided when the lookup arrives, as ZeroKMS would + // decide it; holding only delays its return. + let refusal = asked + .as_deref() + .and_then(|name| self.refused.lock().expect("lock").get(name).copied()); + let held = { + let mut held = self.held.lock().expect("lock"); + if held.as_deref() == asked.as_deref() && asked.is_some() { + *held = None; + true + } else { + false + } + }; + if held { + while !self.released.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + } + match refusal { + Some(how) => { + let (kind, message) = match how { + Refusal::Unknown => { + (ViturRequestErrorKind::NotFound, "no keyset has this name") + } + Refusal::Unreachable => (ViturRequestErrorKind::SendRequest, "no answer"), + }; + Err(stack_kms::Error::from(LoadKeysetError::from( + ViturRequestError::new(kind, message, std::io::Error::other(message)), + ))) + } + None => self.inner.load_index_key(keyset_id).await, + } + } +} + +fn name(name: &str) -> IdentifiedBy { + IdentifiedBy::Name(name.to_string().into()) +} + +async fn cipher() -> StackCipher<Observed> { + StackCipher::builder() + .kms(Observed::default()) + .init() + .await + .expect("build cipher") +} + +// ============================================================================= +// Selection and caching +// ============================================================================= + +#[tokio::test] +async fn init_loads_the_default_keyset_once() { + let cipher = cipher().await; + assert_eq!(cipher.kms().loads(), 1, "the default keyset loads at init"); + + let expected = FakeDataKeySource::new() + .load_index_key(None) + .await + .expect("resolve") + .0; + assert_eq!( + cipher.default_keyset().keyset_id(), + expected, + "init resolves the source's own default keyset" + ); + assert_eq!( + cipher.default_keyset().keyset_name(), + None, + "the client's default is resolved by naming nothing, so it has no name" + ); + assert_eq!(cipher.kms().loads(), 1, "default_keyset() never loads"); +} + +/// The default is the client's, and stays the client's. A ZeroKMS +/// administrator sets it; selecting other keysets — however many, however +/// recently — never moves it. +#[tokio::test] +async fn selecting_a_keyset_never_moves_the_default() { + let cipher = cipher().await; + let default = cipher.default_keyset().keyset_id(); + + let customers = cipher.keyset(name("customers")).await.expect("select"); + assert_ne!(customers.keyset_id(), default, "a distinct keyset"); + assert_eq!( + cipher.default_keyset().keyset_id(), + default, + "the default is unchanged by a selection" + ); + + let _ = cipher.keyset(name("acme")).await.expect("select another"); + assert_eq!( + cipher.default_keyset().keyset_id(), + default, + "and by any number of them" + ); + assert_eq!( + cipher.default_keyset().keyset_name(), + None, + "the client's default is never a name the caller chose" + ); +} + +#[tokio::test] +async fn a_keyset_loads_on_first_selection_and_is_cached_after() { + let cipher = cipher().await; + let first = cipher.keyset(name("acme")).await.expect("select"); + assert_eq!(cipher.kms().loads(), 2, "first selection loads"); + assert_eq!( + first.keyset_name(), + Some("acme"), + "a keyset selected by name reports the name it was selected by" + ); + + let again = cipher.keyset(name("acme")).await.expect("select again"); + let by_id = cipher + .keyset(first.keyset_id()) + .await + .expect("select by id"); + assert_eq!(cipher.kms().loads(), 2, "later selections are lookups"); + assert_eq!( + again.keyset_id(), + first.keyset_id(), + "the second selection by name is the same keyset" + ); + assert_eq!( + by_id.keyset_id(), + first.keyset_id(), + "and so is the selection by the id it resolved to" + ); + assert_eq!( + by_id.keyset_name(), + Some("acme"), + "the cached state keeps the name" + ); +} + +#[tokio::test] +async fn a_keyset_selected_by_id_is_not_known_by_name() { + let cipher = cipher().await; + let by_id = cipher.keyset(Uuid::from_u128(42)).await.expect("select"); + assert_eq!( + by_id.keyset_name(), + None, + "a selection by id knows no name to report" + ); + assert_eq!(cipher.kms().loads(), 2, "default + the selected keyset"); +} + +#[tokio::test] +async fn an_evicted_keyset_reloads_on_its_next_selection() { + let cipher = StackCipher::builder() + .kms(Observed::default()) + .keyset_cache_size(NonZeroUsize::new(1).expect("non-zero")) + .init() + .await + .expect("build cipher"); + + let a = cipher.keyset(Uuid::from_u128(1)).await.expect("a"); + let _b = cipher.keyset(Uuid::from_u128(2)).await.expect("b"); + assert_eq!(cipher.kms().loads(), 3, "default + a + b"); + + // `a` was evicted by `b`; selecting it again is a load. The handle taken + // earlier is unaffected: it holds its own state. + let a_again = cipher.keyset(Uuid::from_u128(1)).await.expect("a again"); + assert_eq!( + cipher.kms().loads(), + 4, + "an evicted keyset is loaded again on its next selection" + ); + assert_eq!( + a_again.keyset_id(), + a.keyset_id(), + "the reload is the same keyset" + ); + + // The default never evicts, however small the cache. + let _ = cipher.default_keyset(); + let _ = cipher + .keyset(cipher.default_keyset().keyset_id()) + .await + .expect("default by id"); + assert_eq!( + cipher.kms().loads(), + 4, + "the default never evicts, however small the cache" + ); +} + +/// A name is a lookup, not an identity: past the window, selecting a keyset +/// by name asks ZeroKMS again, while selecting by id never does. +#[tokio::test] +async fn a_name_selection_is_re_resolved_after_its_window() { + let cipher = StackCipher::builder() + .kms(Observed::default()) + .keyset_name_ttl(Duration::ZERO) + .init() + .await + .expect("build cipher"); + assert_eq!(cipher.kms().loads(), 1, "init loads the default keyset"); + + let acme = cipher.keyset(name("acme")).await.expect("acme"); + let _ = cipher.keyset(name("acme")).await.expect("acme again"); + assert_eq!( + cipher.kms().loads(), + 3, + "every selection by name asks again" + ); + + let _ = cipher.keyset(acme.keyset_id()).await.expect("acme by id"); + let _ = cipher + .keyset(acme.keyset_id()) + .await + .expect("acme by id again"); + assert_eq!( + cipher.kms().loads(), + 3, + "an id is identity and is never re-asked" + ); + + let _ = cipher.keyset(name("primary")).await.expect("primary"); + assert_eq!(cipher.kms().loads(), 4, "another name, another ask"); + let _ = cipher + .keyset(cipher.default_keyset().keyset_id()) + .await + .expect("default by id"); + assert_eq!( + cipher.kms().loads(), + 4, + "the client's default is seeded by id, and an id is never re-asked" + ); +} + +/// ZeroKMS's own answer that no keyset has a name reaches the caller as it +/// is, and a request that got no answer as a request failure. Neither +/// touches what is cached by id: the name was answered, not the keyset. A +/// zero window, so every selection by name asks ZeroKMS and can be refused. +#[tokio::test] +async fn a_refused_name_is_zerokms_answer_and_leaves_the_keyset_cached_by_id() { + let cipher = StackCipher::builder() + .kms(Observed::default()) + .keyset_name_ttl(Duration::ZERO) + .init() + .await + .expect("build cipher"); + let acme = cipher.keyset(name("acme")).await.expect("acme"); + assert_eq!(cipher.kms().loads(), 2, "init and acme"); + + cipher.kms().refuse("acme", Refusal::Unknown); + let error = cipher.keyset(name("acme")).await.expect_err("refused"); + assert!( + matches!( + error, + Error::Kms(stack_kms::Error::LoadKeyset( + LoadKeysetError::KeysetNotFound(_) + )) + ), + "ZeroKMS's not-found reaches the caller as it is, got {error:?}" + ); + let by_id = cipher.keyset(acme.keyset_id()).await.expect("acme by id"); + assert_eq!( + cipher.kms().loads(), + 3, + "the keyset is still cached by id: only the name was answered" + ); + assert_eq!(by_id.keyset_id(), acme.keyset_id()); + + cipher.kms().refuse("acme", Refusal::Unreachable); + let error = cipher.keyset(name("acme")).await.expect_err("failed"); + assert!( + matches!( + error, + Error::Kms(stack_kms::Error::LoadKeyset( + LoadKeysetError::RequestFailed(_) + )) + ), + "a request that got no answer is a request failure, got {error:?}" + ); + + cipher.kms().allow("acme"); + let again = cipher.keyset(name("acme")).await.expect("acme once more"); + assert_eq!( + again.keyset_id(), + acme.keyset_id(), + "the fake resolves a name deterministically" + ); +} + +/// The race the negative answer exists for, end to end: a lookup for `acme` +/// is in flight when ZeroKMS tells a later lookup that no keyset has the +/// name. The earlier answer still reaches its own caller, but it binds +/// nothing — a selection after it asks ZeroKMS, instead of being routed to +/// the keyset the name no longer means for a whole window. +#[tokio::test] +async fn an_answer_in_flight_does_not_rebind_a_name_zerokms_has_since_refused() { + let cipher = cipher().await; + cipher.kms().hold("acme"); + let earlier = cipher.keyset(name("acme")); + let meanwhile = async { + cipher.kms().refuse("acme", Refusal::Unknown); + let refused = cipher.keyset(name("acme")).await; + cipher.kms().release(); + refused + }; + // `join!` polls in order: the earlier lookup takes its ticket and parks + // on the hold, the later one is refused, and the release lets the + // earlier answer land last. + let (earlier, refused) = tokio::join!(earlier, meanwhile); + let earlier = earlier.expect("the earlier lookup's own answer stands for its caller"); + assert!( + matches!( + refused, + Err(Error::Kms(stack_kms::Error::LoadKeyset( + LoadKeysetError::KeysetNotFound(_) + ))) + ), + "the later lookup was refused, got {refused:?}" + ); + assert_eq!( + cipher.kms().loads(), + 3, + "init, the held lookup, the refused one" + ); + + cipher.kms().allow("acme"); + let later = cipher.keyset(name("acme")).await.expect("acme afterwards"); + assert_eq!( + cipher.kms().loads(), + 4, + "the earlier answer bound nothing: a selection by the name asks ZeroKMS" + ); + assert_eq!( + later.keyset_id(), + earlier.keyset_id(), + "the fake resolves a name deterministically; what differs is that it was asked" + ); +} + +/// The mirror of the race above: the later lookup gets no answer at all. +/// That says nothing about the name, so the earlier answer binds it as +/// usual and the next selection is served from the binding. This is the +/// line between ZeroKMS's own not-found and a request that failed to reach +/// it — only the former is an answer. +#[tokio::test] +async fn a_lookup_that_got_no_answer_forgets_nothing() { + let cipher = cipher().await; + cipher.kms().hold("acme"); + let earlier = cipher.keyset(name("acme")); + let meanwhile = async { + cipher.kms().refuse("acme", Refusal::Unreachable); + let failed = cipher.keyset(name("acme")).await; + cipher.kms().release(); + failed + }; + let (earlier, failed) = tokio::join!(earlier, meanwhile); + let earlier = earlier.expect("the earlier lookup's answer stands"); + assert!( + matches!( + failed, + Err(Error::Kms(stack_kms::Error::LoadKeyset( + LoadKeysetError::RequestFailed(_) + ))) + ), + "the later lookup got no answer, got {failed:?}" + ); + assert_eq!( + cipher.kms().loads(), + 3, + "init, the held lookup, the failed one" + ); + + cipher.kms().allow("acme"); + let later = cipher.keyset(name("acme")).await.expect("acme afterwards"); + assert_eq!( + cipher.kms().loads(), + 3, + "a failure to get an answer forgot nothing: the earlier answer bound the name and serves" + ); + assert_eq!(later.keyset_id(), earlier.keyset_id()); +} + +#[tokio::test] +async fn keysets_derive_distinct_index_keys() { + let cipher = cipher().await; + let a = cipher.keyset(Uuid::from_u128(1)).await.expect("a"); + let b = cipher.keyset(Uuid::from_u128(2)).await.expect("b"); + + let term_a = a + .equality_term(7u32, nonempty!("users/age")) + .await + .expect("term"); + let term_b = b + .equality_term(7u32, nonempty!("users/age")) + .await + .expect("term"); + let term_a_again = a + .equality_term(7u32, nonempty!("users/age")) + .await + .expect("term"); + + assert_ne!(term_a, term_b, "different keysets, different index keys"); + assert_eq!( + term_a, term_a_again, + "the same keyset derives the same term" + ); +} + +// ============================================================================= +// The leaf carries its keyset +// ============================================================================= + +fn leaf_of(tree: StackCipherText) -> SealedValue { + match tree { + CipherText::Single(leaf) => leaf, + _ => panic!("a scalar seals to a single leaf"), + } +} + +#[tokio::test] +async fn a_sealed_leaf_names_the_keyset_it_was_sealed_under() { + let cipher = cipher().await; + let tenant = cipher.keyset(name("acme")).await.expect("select"); + + let sealed = tenant + .encrypt("hello".to_string(), "greeting") + .await + .expect("seal"); + assert_eq!( + leaf_of(sealed).keyset_id(), + tenant.keyset_id(), + "a leaf carries the keyset it was sealed under" + ); + + let sealed = cipher + .default_keyset() + .encrypt("hello".to_string(), "greeting") + .await + .expect("seal"); + assert_eq!( + leaf_of(sealed).keyset_id(), + cipher.default_keyset().keyset_id(), + "and so does one sealed through the default keyset" + ); +} + +// ============================================================================= +// Decrypting: the client opens any keyset, a keyset handle only its own +// ============================================================================= + +#[tokio::test] +async fn the_client_opens_a_leaf_from_any_keyset() { + let cipher = cipher().await; + let tenant = cipher.keyset(name("acme")).await.expect("select"); + let sealed = tenant + .encrypt("hello".to_string(), "greeting") + .await + .expect("seal"); + + let opened: String = cipher.decrypt(sealed, "greeting").await.expect("open"); + assert_eq!(opened, "hello", "the client opens another keyset's leaf"); + assert_eq!( + cipher.kms().retrieve_keysets(), + vec![Some(tenant.keyset_id())], + "the retrieve names the leaf's keyset, not the default" + ); +} + +#[tokio::test] +async fn a_keyset_handle_opens_its_own_leaves() { + let cipher = cipher().await; + let tenant = cipher.keyset(name("acme")).await.expect("select"); + let sealed = tenant + .encrypt("hello".to_string(), "greeting") + .await + .expect("seal"); + + let opened: String = tenant.decrypt(sealed, "greeting").await.expect("open"); + assert_eq!(opened, "hello", "a keyset handle opens its own leaf"); +} + +#[tokio::test] +async fn a_keyset_handle_refuses_another_keysets_leaf_before_any_retrieve() { + let cipher = cipher().await; + let acme = cipher.keyset(name("acme")).await.expect("acme"); + let globex = cipher.keyset(name("globex")).await.expect("globex"); + let sealed = acme + .encrypt("hello".to_string(), "greeting") + .await + .expect("seal"); + + let result: Result<String, _> = globex.decrypt(sealed, "greeting").await; + assert!( + matches!( + result, + Err(Error::ForeignKeyset { expected, found }) + if expected == globex.keyset_id() && found == acme.keyset_id() + ), + "{result:?}" + ); + assert!( + cipher.kms().retrieve_keysets().is_empty(), + "refused before any key was retrieved" + ); +} + +#[tokio::test] +async fn the_target_path_through_a_keyset_handle_is_constrained_too() { + let cipher = cipher().await; + let acme = cipher.keyset(name("acme")).await.expect("acme"); + let globex = cipher.keyset(name("globex")).await.expect("globex"); + // A ciphertext tree is not `Clone`; seal three, one per path. + let seal = || async { + let sealed: StackCipherText = 34u32 + .encrypt_into_with_context(&acme, nonempty!("users/age")) + .await + .expect("seal"); + sealed + }; + + let opened: u32 = seal() + .await + .decrypt_into(&acme, nonempty!("users/age")) + .await + .expect("own keyset opens"); + assert_eq!(opened, 34, "the sealing keyset opens its own leaf"); + + let opened: u32 = seal() + .await + .decrypt_into(&cipher, nonempty!("users/age")) + .await + .expect("the client opens"); + assert_eq!(opened, 34, "and so does the client it belongs to"); + + let result: Result<u32, _> = seal() + .await + .decrypt_into(&globex, nonempty!("users/age")) + .await; + assert!( + matches!(result, Err(Error::ForeignKeyset { .. })), + "{result:?}" + ); +} + +#[tokio::test] +async fn a_mixed_keyset_column_opens_through_the_client_in_one_call_per_keyset() { + let cipher = cipher().await; + let acme = cipher.keyset(name("acme")).await.expect("acme"); + let globex = cipher.keyset(name("globex")).await.expect("globex"); + + // A column whose rows belong to two tenants, interleaved. + let mut column: Vec<StackCipherText> = Vec::new(); + for (i, tenant) in [(1u32, &acme), (2, &globex), (3, &acme)] { + let sealed: StackCipherText = i + .encrypt_into_with_context(tenant, nonempty!("users/age")) + .await + .expect("seal"); + column.push(sealed); + } + + let opened: Vec<u32> = column + .decrypt_into(&cipher, nonempty!("users/age")) + .await + .expect("open"); + assert_eq!( + opened, + vec![1, 2, 3], + "a two-tenant column opens in row order" + ); + assert_eq!( + cipher.kms().retrieve_keysets(), + vec![Some(acme.keyset_id()), Some(globex.keyset_id())], + "one retrieve per keyset, first seen first" + ); +} + +#[tokio::test] +async fn a_mixed_keyset_column_does_not_open_through_a_keyset_handle() { + let cipher = cipher().await; + let acme = cipher.keyset(name("acme")).await.expect("acme"); + let globex = cipher.keyset(name("globex")).await.expect("globex"); + let mut column: Vec<StackCipherText> = Vec::new(); + for tenant in [&acme, &globex] { + let sealed: StackCipherText = 1u32 + .encrypt_into_with_context(tenant, nonempty!("users/age")) + .await + .expect("seal"); + column.push(sealed); + } + + let result: Result<Vec<u32>, _> = column.decrypt_into(&acme, nonempty!("users/age")).await; + assert!( + matches!(result, Err(Error::ForeignKeyset { .. })), + "{result:?}" + ); + assert!( + cipher.kms().retrieve_keysets().is_empty(), + "refused before any key was retrieved" + ); +} diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs new file mode 100644 index 000000000..985f65792 --- /dev/null +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -0,0 +1,534 @@ +//! End-to-end encrypt/decrypt tests for `StackCipher` against the in-memory +//! `FakeDataKeySource` — no ZeroKMS credentials or network required. The fake +//! hands out random key material and remembers it by `(iv, tag)`, so +//! generate → retrieve round-trips within a test but nothing is reproducible +//! across processes. + +use std::collections::HashMap; + +use stack_encrypt::{ + BoxedPassthrough, Cipher, CipherText, ContextTag, Element, Encrypt, IntoAad, SealedValue, + StackCipher, +}; +use stack_kms::FakeDataKeySource; +use vitaminc_aead::{MapCipher, Passthrough}; +use vitaminc_protected::{Controlled, Protected}; + +async fn cipher() -> StackCipher<FakeDataKeySource> { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher") +} + +#[tokio::test] +async fn scalar_roundtrips_with_no_aad() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt("hello world".to_string(), ()) + .await + .expect("encrypt"); + let pt: String = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert_eq!(pt, "hello world"); +} + +#[tokio::test] +async fn scalar_roundtrips_with_matching_aad() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let aad = b"public-context".as_slice(); + let ct = keyset + .encrypt("secret".to_string(), aad) + .await + .expect("encrypt"); + let pt: String = cipher.decrypt(ct, aad).await.expect("decrypt"); + assert_eq!(pt, "secret"); +} + +#[tokio::test] +async fn decrypt_fails_with_wrong_aad() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt("secret".to_string(), b"aad-a".as_slice()) + .await + .expect("encrypt"); + let result: Result<String, _> = cipher.decrypt(ct, b"aad-b".as_slice()).await; + assert!(result.is_err(), "mismatched AAD must not decrypt"); +} + +#[tokio::test] +async fn decrypt_fails_when_aad_omitted() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt("secret".to_string(), b"bound".as_slice()) + .await + .expect("encrypt"); + // The leaf bound the PAE-encoded tuple `(aad, tag)`; dropping the caller + // AAD changes the encoding. + let result: Result<String, _> = cipher.decrypt(ct, ()).await; + assert!(result.is_err(), "omitting the bound AAD must not decrypt"); +} + +#[tokio::test] +async fn vec_roundtrips() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let items = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let ct = keyset.encrypt(items.clone(), ()).await.expect("encrypt"); + let pt: Vec<String> = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert_eq!(pt, items); +} + +#[tokio::test] +async fn map_roundtrips() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + // Encrypt side keys are `&'static str`; decrypt side yields `String` keys. + let mut input: HashMap<&'static str, String> = HashMap::new(); + input.insert("name", "alice".to_string()); + input.insert("role", "admin".to_string()); + + let ct = keyset.encrypt(input, ()).await.expect("encrypt"); + let pt: HashMap<String, String> = cipher.decrypt(ct, ()).await.expect("decrypt"); + + assert_eq!(pt.get("name"), Some(&"alice".to_string())); + assert_eq!(pt.get("role"), Some(&"admin".to_string())); + assert_eq!(pt.len(), 2); +} + +#[tokio::test] +async fn option_some_roundtrips() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt(Some("present".to_string()), ()) + .await + .expect("encrypt"); + let pt: Option<String> = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert_eq!(pt, Some("present".to_string())); +} + +#[tokio::test] +async fn option_none_roundtrips() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt(Option::<String>::None, ()) + .await + .expect("encrypt"); + let pt: Option<String> = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert_eq!(pt, None); +} + +#[tokio::test] +async fn protected_roundtrip() { + // Exercises the `Protected` Decrypt impl, the sole user of `Decipher::map_ok`. + // (`Vec<u8>` would encrypt element-wise as a sequence of `u8`, not as bytes, + // so a string leaf is used here.) + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let secret = Protected::new("classified".to_string()); + let ct = keyset.encrypt(secret, ()).await.expect("encrypt"); + let pt: Protected<String> = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert_eq!(pt.risky_unwrap(), "classified".to_string()); +} + +#[tokio::test] +async fn nested_vec_roundtrips() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let nested = vec![ + vec!["a".to_string(), "b".to_string()], + vec!["c".to_string()], + ]; + let ct = keyset.encrypt(nested.clone(), ()).await.expect("encrypt"); + let pt: Vec<Vec<String>> = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert_eq!(pt, nested); +} + +#[tokio::test] +async fn context_tag_binds_and_roundtrips() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt(ContextTag::new("token".to_string(), "user:42"), ()) + .await + .expect("encrypt"); + + // Matching context recovers the value. + let pt: String = cipher + .decrypt(ct, ContextTag::aad("user:42")) + .await + .expect("decrypt"); + assert_eq!(pt, "token"); +} + +#[tokio::test] +async fn context_tag_wrong_context_fails() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt(ContextTag::new("token".to_string(), "user:42"), ()) + .await + .expect("encrypt"); + + let result: Result<String, _> = cipher.decrypt(ct, ContextTag::aad("user:99")).await; + assert!(result.is_err(), "wrong context tag must not decrypt"); +} + +#[tokio::test] +async fn empty_vec_roundtrips() { + // An empty sequence seals an authenticated marker, so emptiness is provable. + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt(Vec::<String>::new(), ()) + .await + .expect("encrypt"); + let pt: Vec<String> = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert!(pt.is_empty()); +} + +#[tokio::test] +async fn empty_map_roundtrips() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt(HashMap::<&'static str, String>::new(), ()) + .await + .expect("encrypt"); + let pt: HashMap<String, String> = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert!(pt.is_empty()); +} + +#[tokio::test] +async fn empty_marker_does_not_decode_under_wrong_aad() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt(Vec::<String>::new(), b"bound".as_slice()) + .await + .expect("encrypt"); + let result: Result<Vec<String>, _> = cipher.decrypt(ct, ()).await; + assert!(result.is_err(), "empty marker must authenticate its AAD"); +} + +/// A hand-written record carrying one field in the clear, through the +/// type-erased `passthrough_entry_boxed` a cipher-generic `Encrypt` has to +/// use. +struct Row { + id: u32, + email: String, + repeat_id: bool, +} + +impl Row { + fn once() -> Self { + Self { + id: 7, + email: "a@x".to_string(), + repeat_id: false, + } + } + + fn with_duplicate_id() -> Self { + Self { + repeat_id: true, + ..Self::once() + } + } +} + +impl Encrypt for Row { + fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result<C::Ok, C::Error> + where + C: Cipher, + A: IntoAad<'a>, + { + let mut map = cipher + .encrypt_map(aad) + .passthrough_entry_boxed("id", Box::new(self.id))?; + if self.repeat_id { + map = map.passthrough_entry_boxed("id", Box::new(self.id))?; + } + map.encrypt_entry("email", self.email)?.end() + } +} + +/// A passthrough entry lands in the map as given, beside the sealed ones; +/// a key given twice is refused like any repeated map key, since a map the +/// cipher would refuse to open must never be produced. +#[tokio::test] +async fn a_passthrough_map_entry_is_stored_in_the_clear_once() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset.encrypt(Row::once(), ()).await.expect("encrypt"); + let CipherText::Map(entries) = ct else { + panic!("a record encrypts as a map: {ct:?}"); + }; + let entry = |name: &str| entries.iter().find(|(key, _)| key == name).map(|(_, v)| v); + assert!( + matches!(entry("id"), Some(CipherText::Passthrough(v)) if v.downcast_ref::<u32>() == Some(&7)), + "{entries:?}" + ); + assert!( + matches!(entry("email"), Some(CipherText::Single(_))), + "{entries:?}" + ); + + let result = keyset.encrypt(Row::with_duplicate_id(), ()).await; + assert!(result.is_err(), "a passthrough key given twice is refused"); +} + +/// A container is authenticated by the sealed nodes inside it, so one with +/// none — no entries at all, or only passthroughs, which carry no tag — +/// would verify under *any* AAD. The encrypt side never produces one +/// (emptiness is the authenticated `Empty*` marker); the decrypt side must +/// refuse to open one, even for a type that reads passthroughs back. +#[tokio::test] +async fn a_container_with_nothing_sealed_in_it_does_not_open() { + let cipher = cipher().await; + let aad = b"users".as_slice(); + let clear = |value: u32| CipherText::Passthrough(Box::new(value) as BoxedPassthrough); + + let result: Result<Vec<String>, _> = cipher.decrypt(CipherText::Sequence(vec![]), aad).await; + assert!(result.is_err(), "an entry-less sequence proves nothing"); + let result: Result<HashMap<String, String>, _> = + cipher.decrypt(CipherText::Map(vec![]), aad).await; + assert!(result.is_err(), "an entry-less map proves nothing"); + + let result: Result<Vec<Passthrough<u32>>, _> = cipher + .decrypt(CipherText::Sequence(vec![clear(1), clear(2)]), aad) + .await; + assert!( + result.is_err(), + "an all-passthrough sequence proves nothing" + ); + let result: Result<HashMap<String, Passthrough<u32>>, _> = cipher + .decrypt(CipherText::Map(vec![("id".to_string(), clear(1))]), aad) + .await; + assert!(result.is_err(), "an all-passthrough map proves nothing"); +} + +#[tokio::test] +async fn renamed_map_key_fails() { + // Map keys travel in the clear but are bound into their value's AAD, so + // renaming a key in the stored ciphertext must fail decryption. + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let mut input: HashMap<&'static str, String> = HashMap::new(); + input.insert("name", "alice".to_string()); + + let ct = keyset.encrypt(input, ()).await.expect("encrypt"); + let tampered = match ct { + CipherText::Map(entries) => CipherText::Map( + entries + .into_iter() + .map(|(_, v)| ("role".to_string(), v)) + .collect(), + ), + other => other, + }; + + let result: Result<HashMap<String, String>, _> = cipher.decrypt(tampered, ()).await; + assert!(result.is_err(), "renamed map key must not decrypt"); +} + +#[tokio::test] +async fn sequence_element_cannot_be_rehomed_as_scalar() { + // Elements are sealed under the `for_sequence_element` derivation, so a + // leaf spliced out of a sequence must not verify as a top-level scalar. + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt(vec!["a".to_string()], ()) + .await + .expect("encrypt"); + let element = match ct { + CipherText::Sequence(mut items) => items.remove(0), + other => other, + }; + + let result: Result<String, _> = cipher.decrypt(element, ()).await; + assert!( + result.is_err(), + "re-homed sequence element must not decrypt" + ); +} + +#[tokio::test] +async fn element_roundtrips_under_bare_caller_aad() { + // `Element<T>` derives `for_sequence_element` inside its own Encrypt/Decrypt + // impls. Both sides must honour that derivation: the decipher opens the leaf + // under the AAD the Decrypt drive supplies, not a pre-derived one. + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt(Element("row".to_string()), b"users".as_slice()) + .await + .expect("encrypt"); + let pt: Element<String> = cipher + .decrypt(ct, b"users".as_slice()) + .await + .expect("Element must round-trip under the bare caller AAD"); + assert_eq!(pt.into_inner(), "row"); +} + +#[tokio::test] +async fn element_interchanges_with_vec_element() { + // A row sealed as one element of a `Vec` decrypts alone as `Element<T>` + // under the same caller AAD (Element's documented use-case), and a lone + // `Element` ciphertext decrypts as a one-element `Vec`. + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let aad = b"users".as_slice(); + + let ct = keyset + .encrypt(vec!["a".to_string(), "b".to_string()], aad) + .await + .expect("encrypt"); + let second = match ct { + CipherText::Sequence(mut items) => items.remove(1), + other => other, + }; + let pt: Element<String> = cipher + .decrypt(second, aad) + .await + .expect("spliced element must decrypt as Element"); + assert_eq!(pt.into_inner(), "b"); + + let lone = keyset + .encrypt(Element("c".to_string()), aad) + .await + .expect("encrypt"); + let wrapped = CipherText::Sequence(vec![lone]); + let pt: Vec<String> = cipher + .decrypt(wrapped, aad) + .await + .expect("lone Element must decrypt as a one-element Vec"); + assert_eq!(pt, vec!["c".to_string()]); +} + +#[tokio::test] +async fn element_fails_under_wrong_caller_aad() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt(Element("row".to_string()), b"users".as_slice()) + .await + .expect("encrypt"); + let result: Result<Element<String>, _> = cipher.decrypt(ct, b"orders".as_slice()).await; + assert!( + result.is_err(), + "Element under the wrong caller AAD must not decrypt" + ); +} + +/// The derivation that binds a sequence element to its position is the +/// library's, applied by `Element<T>` itself — there is no entry point that +/// lets a caller retrieve keys under one context and authenticate under +/// another, so a batched row is read back by naming the type, not by +/// reconstructing the AAD. +#[tokio::test] +async fn a_batched_element_opens_by_naming_the_type() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt(Element("row".to_string()), b"users".as_slice()) + .await + .expect("encrypt"); + let opened: Element<String> = cipher + .decrypt(ct, b"users".as_slice()) + .await + .expect("Element applies its own derivation on open"); + assert_eq!(opened.into_inner(), "row"); +} + +#[tokio::test] +async fn wrong_shape_fails() { + // A scalar ciphertext must not decode as a sequence. + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt("scalar".to_string(), ()) + .await + .expect("encrypt"); + let result: Result<Vec<String>, _> = cipher.decrypt(ct, ()).await; + assert!(result.is_err(), "scalar must not decode as a Vec"); +} + +#[tokio::test] +async fn leaf_survives_persistence_via_parts() { + // A leaf can be decomposed into (keyset_id, iv, tag, ciphertext), stored, + // and rebuilt + // — the in-memory original need not be retained to decrypt. + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt("durable".to_string(), b"ctx".as_slice()) + .await + .expect("encrypt"); + let leaf = match ct { + CipherText::Single(leaf) => leaf, + other => panic!("expected a Single leaf, got {other:?}"), + }; + let (keyset_id, iv, tag, bytes) = leaf.into_parts(); + let rebuilt = SealedValue::from_parts(keyset_id, iv, tag, bytes).expect("rebuild leaf"); + + let pt: String = cipher + .decrypt(CipherText::Single(rebuilt), b"ctx".as_slice()) + .await + .expect("rebuilt leaf must decrypt"); + assert_eq!(pt, "durable"); +} + +#[tokio::test] +async fn leaf_survives_persistence_via_serde() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt("durable".to_string(), ()) + .await + .expect("encrypt"); + let leaf = match ct { + CipherText::Single(leaf) => leaf, + other => panic!("expected a Single leaf, got {other:?}"), + }; + let json = serde_json::to_string(&leaf).expect("serialise leaf"); + let restored: SealedValue = serde_json::from_str(&json).expect("deserialise leaf"); + assert_eq!(restored.keyset_id(), leaf.keyset_id()); + assert_eq!(restored.iv(), leaf.iv()); + assert_eq!(restored.tag(), leaf.tag()); + assert_eq!(restored.ciphertext(), leaf.ciphertext()); + + let pt: String = cipher + .decrypt(CipherText::Single(restored), ()) + .await + .expect("restored leaf must decrypt"); + assert_eq!(pt, "durable"); +} + +#[tokio::test] +async fn tampered_leaf_bytes_fail() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ct = keyset + .encrypt("durable".to_string(), ()) + .await + .expect("encrypt"); + let leaf = match ct { + CipherText::Single(leaf) => leaf, + other => panic!("expected a Single leaf, got {other:?}"), + }; + let (keyset_id, iv, tag, mut bytes) = leaf.into_parts(); + let last = bytes.len() - 1; + bytes[last] ^= 0x01; + let tampered = SealedValue::from_parts(keyset_id, iv, tag, bytes).expect("rebuild leaf"); + + let result: Result<String, _> = cipher.decrypt(CipherText::Single(tampered), ()).await; + assert!(result.is_err(), "a flipped ciphertext bit must not decrypt"); +} diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs new file mode 100644 index 000000000..aebd1ad2f --- /dev/null +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -0,0 +1,425 @@ +//! SEM term-generation tests against the local HMAC PRF backend, keyed by the +//! deterministic fake index key — no ZeroKMS credentials or network required. + +use std::cmp::Ordering; + +use stack_encrypt::nonempty; +use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions, MatchTerm, Tokenizer}; +use stack_encrypt::{Error, StackCipher}; +use stack_kms::{FakeDataKeySource, IdentifiedBy}; +use uuid::Uuid; + +/// Type-level config with the v1 `Standard` (word) tokenizer. +struct WordMatch; + +impl MatchConfig for WordMatch { + fn options() -> MatchOptions { + MatchOptions { + tokenizer: Tokenizer::Standard, + ..Default::default() + } + } +} + +/// A config whose options fail validation at term-generation time. +macro_rules! bad_config { + ($name:ident, $($field:ident: $value:expr),+ $(,)?) => { + struct $name; + impl MatchConfig for $name { + fn options() -> MatchOptions { + MatchOptions { $($field: $value,)+ ..Default::default() } + } + } + }; +} + +bad_config!(TooBigK, k: 17); +bad_config!(TooSmallK, k: 1); +bad_config!(NonPowerOfTwoM, m: 100); +bad_config!(TooSmallM, m: 16); +bad_config!(ZeroNgram, tokenizer: Tokenizer::Ngram { length: 0 }); + +async fn generator() -> StackCipher<FakeDataKeySource> { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher") +} + +async fn generator_for(keyset: Uuid) -> StackCipher<FakeDataKeySource> { + let cipher = generator().await; + // Warm the cache so the caller's `keyset(..)` is a lookup; the cipher's + // own default stays the client's, which is not ours to choose. + let _ = cipher + .keyset(IdentifiedBy::Uuid(keyset)) + .await + .expect("select keyset"); + cipher +} + +#[tokio::test] +async fn equality_terms_are_deterministic() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + let a = gen + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + let b = gen + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + assert_eq!(a, b, "same value + descriptor must yield the same term"); +} + +#[tokio::test] +async fn equality_terms_bind_the_descriptor() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + let a = gen + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + let b = gen + .equality_term("alice", nonempty!("users/name")) + .await + .unwrap(); + assert_ne!(a, b, "the descriptor must domain-separate terms"); +} + +#[tokio::test] +async fn equality_terms_differ_by_value() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + let a = gen + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + let b = gen + .equality_term("bob", nonempty!("users/email")) + .await + .unwrap(); + assert_ne!(a, b); +} + +#[tokio::test] +async fn equality_terms_bind_the_index_key() { + let cipher_a = generator_for(Uuid::from_u128(1)).await; + let cipher_b = generator_for(Uuid::from_u128(2)).await; + let gen_a = cipher_a + .keyset(IdentifiedBy::Uuid(Uuid::from_u128(1))) + .await + .expect("keyset 1"); + let gen_b = cipher_b + .keyset(IdentifiedBy::Uuid(Uuid::from_u128(2))) + .await + .expect("keyset 2"); + let a = gen_a + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + let b = gen_b + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + assert_ne!(a, b, "different keysets must yield different terms"); +} + +#[tokio::test] +async fn match_query_terms_are_contained_in_stored_terms() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + + let stored = gen + .match_terms::<DefaultMatch>("alice wonderland", nonempty!("users/bio")) + .await + .unwrap(); + let query = gen + .match_terms::<DefaultMatch>("wonder", nonempty!("users/bio")) + .await + .unwrap(); + + assert!( + stored.contains(&query), + "a substring's tokens must be contained in the stored term" + ); +} + +/// Containment is a superset test, not an overlap test, and an empty probe +/// matches nothing — pinned on positions directly, so the verdict does not +/// hang on which bits a PRF happened to set. +#[test] +fn match_containment_needs_every_query_position() { + let term = |positions: &[u16]| { + MatchTerm::<DefaultMatch>::from_positions(positions.to_vec()) + .expect("positions inside the default filter") + }; + let stored = term(&[3, 17, 200]); + + assert!(stored.contains(&term(&[3, 200])), "a subset is contained"); + assert!( + stored.contains(&term(&[3, 17, 200])), + "the set itself is contained" + ); + assert!( + !stored.contains(&term(&[3, 18])), + "one missing position is enough to miss" + ); + assert!( + !stored.contains(&term(&[])), + "an empty probe must not match every row" + ); + assert!( + !term(&[]).contains(&term(&[])), + "not even against an empty term" + ); +} + +#[tokio::test] +async fn match_query_terms_for_other_text_are_not_contained() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + + let stored = gen + .match_terms::<DefaultMatch>("alice wonderland", nonempty!("users/bio")) + .await + .unwrap(); + let query = gen + .match_terms::<DefaultMatch>("zebra", nonempty!("users/bio")) + .await + .unwrap(); + + assert!( + !stored.contains(&query), + "text sharing no token with the stored value must not match" + ); +} + +#[tokio::test] +async fn match_is_case_insensitive_by_default() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + + let stored = gen + .match_terms::<DefaultMatch>("Alice", nonempty!("users/name")) + .await + .unwrap(); + let query = gen + .match_terms::<DefaultMatch>("alice", nonempty!("users/name")) + .await + .unwrap(); + assert_eq!(stored, query); +} + +#[tokio::test] +async fn match_binds_the_descriptor() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + + let stored = gen + .match_terms::<DefaultMatch>("alice", nonempty!("users/bio")) + .await + .unwrap(); + let query = gen + .match_terms::<DefaultMatch>("alice", nonempty!("users/name")) + .await + .unwrap(); + assert_ne!(stored, query, "match tokens must be descriptor-bound"); +} + +#[tokio::test] +async fn match_positions_stay_within_the_filter() { + struct SmallFilter; + impl MatchConfig for SmallFilter { + fn options() -> MatchOptions { + MatchOptions { + m: 64, + ..Default::default() + } + } + } + + let cipher = generator().await; + let gen = cipher.default_keyset(); + let term = gen + .match_terms::<SmallFilter>("a longer piece of text", nonempty!("users/bio")) + .await + .unwrap(); + assert!(!term.positions().is_empty()); + assert!(term.positions().iter().all(|&p| u32::from(p) < 64)); + // Sorted + deduped. + assert!(term.positions().windows(2).all(|w| w[0] < w[1])); +} + +#[tokio::test] +async fn match_rejects_invalid_options() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + + // The v1 match indexer's bounds apply: k in 3..=16, m a power of two in + // [32, 65536]. + assert!(gen + .match_terms::<TooBigK>("xxx", nonempty!("d")) + .await + .is_err()); + assert!(gen + .match_terms::<TooSmallK>("xxx", nonempty!("d")) + .await + .is_err()); + assert!(gen + .match_terms::<NonPowerOfTwoM>("xxx", nonempty!("d")) + .await + .is_err()); + assert!(gen + .match_terms::<TooSmallM>("xxx", nonempty!("d")) + .await + .is_err()); + + // A zero-length n-gram must be an options error, not a panic. + assert!(matches!( + gen.match_terms::<ZeroNgram>("xxx", nonempty!("d")).await, + Err(Error::Term(stack_encrypt::sem::TermError::InvalidOptions( + _ + ))) + )); +} + +#[tokio::test] +async fn match_rejects_text_that_yields_no_tokens() { + use stack_encrypt::sem::TermError; + + let cipher = generator().await; + let gen = cipher.default_keyset(); + + // An empty term used as a query would vacuously match every stored row. + for text in ["", " "] { + assert!( + matches!( + gen.match_terms::<DefaultMatch>(text, nonempty!("users/bio")) + .await, + Err(Error::Term(TermError::EmptyTermText)) + ), + "{text:?} must be rejected" + ); + } + + // A probe shorter than the n-gram length could never match a stored gram + // (v1 indexer semantics) — rejected instead of a silent false negative. + assert!(matches!( + gen.match_terms::<DefaultMatch>("hi", nonempty!("users/bio")) + .await, + Err(Error::Term(TermError::EmptyTermText)) + )); + + // Separator-only text under the Standard tokenizer. + assert!(matches!( + gen.match_terms::<WordMatch>(" ,;:! ", nonempty!("users/bio")) + .await, + Err(Error::Term(TermError::EmptyTermText)) + )); +} + +#[tokio::test] +async fn word_tokenizer_matches_whole_words() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + + let stored = gen + .match_terms::<WordMatch>("alice in wonderland", nonempty!("users/bio")) + .await + .unwrap(); + let query = gen + .match_terms::<WordMatch>("wonderland", nonempty!("users/bio")) + .await + .unwrap(); + assert!(stored.contains(&query)); +} + +#[tokio::test] +async fn ore_terms_preserve_order_and_determinism() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + + let ten = gen.ore_term(10u64, nonempty!("users/age")).await.unwrap(); + let ten_again = gen.ore_term(10u64, nonempty!("users/age")).await.unwrap(); + let twenty = gen.ore_term(20u64, nonempty!("users/age")).await.unwrap(); + + assert_eq!(ten, ten_again, "ORE terms must be deterministic"); + assert_eq!(ten.cmp(&twenty), Ordering::Less); +} + +#[tokio::test] +async fn ore_terms_bind_the_descriptor() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + let a = gen.ore_term(10u64, nonempty!("users/age")).await.unwrap(); + let b = gen + .ore_term(10u64, nonempty!("users/height")) + .await + .unwrap(); + assert_ne!(a, b, "per-descriptor ORE keys must differ"); +} + +#[tokio::test] +async fn string_ore_terms_preserve_lexicographic_order() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + let apple = gen + .ore_term("apple", nonempty!("users/name")) + .await + .unwrap(); + let banana = gen + .ore_term("banana", nonempty!("users/name")) + .await + .unwrap(); + assert_eq!(apple.cmp(&banana), Ordering::Less); +} + +#[tokio::test] +async fn ope_terms_compare_with_plain_byte_order() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + + let ten = gen.ope_term(10u64, nonempty!("users/age")).await.unwrap(); + let twenty = gen.ope_term(20u64, nonempty!("users/age")).await.unwrap(); + + // OPE ciphertexts order with standard lexicographic comparison. + assert!(ten.as_ref() < twenty.as_ref()); +} + +#[tokio::test] +async fn ore_and_ope_keys_are_domain_separated() { + // The same descriptor must not derive the same key material for both + // schemes; equal plaintexts should produce different ciphertext bytes. + let cipher = generator().await; + let gen = cipher.default_keyset(); + let ore = gen.ore_term(42u64, nonempty!("users/age")).await.unwrap(); + let ope = gen.ope_term(42u64, nonempty!("users/age")).await.unwrap(); + assert_ne!(ore.as_ref(), ope.as_ref()); +} + +#[tokio::test] +async fn owned_and_borrowed_text_yield_identical_ore_and_ope_terms() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + let borrowed = gen + .ore_term("apple", nonempty!("users/name")) + .await + .unwrap(); + let owned = gen + .ore_term(String::from("apple"), nonempty!("users/name")) + .await + .unwrap(); + assert_eq!(borrowed.as_ref(), owned.as_ref()); + + let borrowed = gen + .ope_term("apple", nonempty!("users/name")) + .await + .unwrap(); + let owned = gen + .ope_term(String::from("apple"), nonempty!("users/name")) + .await + .unwrap(); + assert_eq!(borrowed.as_ref(), owned.as_ref()); +} diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs new file mode 100644 index 000000000..419fce494 --- /dev/null +++ b/packages/stack-encrypt/tests/target.rs @@ -0,0 +1,1125 @@ +//! Target-directed encryption tests: leaf `EncryptFrom`/`DecryptInto` +//! implementations, a hand-written composite record (the shape a future +//! derive will emit), a "third-party" term type built on the public extension +//! surface only, and — the point of the design — proof that however large the +//! assembly, settling it is one batched ZeroKMS call per request kind. + +use std::cmp::Ordering; +use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; +use std::sync::Arc; + +use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; +use stack_encrypt::target::{ + ciphertext, equality, CallerContext, DeclaredContext, DecryptFrom, DecryptInto, Decryption, + EncryptFrom, EncryptInto, Encryption, Pending, Request, +}; +use stack_encrypt::{nonempty, EmptyError, Error, NonEmpty, StackCipher, StackCipherText}; +use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; +use uuid::Uuid; + +mod common; +use common::{counting_cipher, stack_cipher}; + +/// A cipher over the deterministic fake source. Built independently of the +/// test's own [`stack_cipher`]: the fake index key is deterministic per +/// keyset, so two separately built ciphers stand in for the write path and a +/// query path in another process. +async fn generator() -> StackCipher<FakeDataKeySource> { + stack_cipher().await +} + +// --- Leaf implementations --------------------------------------------------- + +#[tokio::test] +async fn equality_leaf_agrees_with_the_descriptor_api() { + let generator = generator().await; + let generator = generator.default_keyset(); + + let via_target: EqualityTerm = "alice" + .encrypt_into_with_context(&generator, nonempty!("users/email")) + .await + .unwrap(); + let via_descriptor = generator + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + + assert_eq!( + via_target, via_descriptor, + "target-directed and descriptor-string call sites must agree on term bytes" + ); +} + +#[tokio::test] +async fn terms_agree_between_independently_built_ciphers() { + // Query-side code in another process, holding its own cipher over the same + // keyset, must produce the same terms + // as write-side code holding the full StackCipher (same index key). + let cipher = stack_cipher().await; + let cipher = cipher.default_keyset(); + let generator = generator().await; + let generator = generator.default_keyset(); + + let a: EqualityTerm = "alice" + .encrypt_into_with_context(&cipher, nonempty!("users/email")) + .await + .unwrap(); + let b: EqualityTerm = "alice" + .encrypt_into_with_context(&generator, nonempty!("users/email")) + .await + .unwrap(); + assert_eq!(a, b); + + let a: OreTerm<u64> = 7u64 + .encrypt_into_with_context(&cipher, nonempty!("users/n")) + .await + .unwrap(); + let b: OreTerm<u64> = 7u64 + .encrypt_into_with_context(&generator, nonempty!("users/n")) + .await + .unwrap(); + assert_eq!(a, b); +} + +#[tokio::test] +async fn equality_leaf_binds_the_context() { + let generator = generator().await; + let generator = generator.default_keyset(); + + let email: EqualityTerm = "alice" + .encrypt_into_with_context(&generator, nonempty!("users/email")) + .await + .unwrap(); + let name: EqualityTerm = "alice" + .encrypt_into_with_context(&generator, nonempty!("users/name")) + .await + .unwrap(); + + assert_ne!(email, name); +} + +#[tokio::test] +async fn term_derivation_makes_no_kms_calls() { + // Under the local HMAC backend, terms derive under the index key the + // cipher already holds, so a query probe settles with no ZeroKMS call. + // That is this backend's property, not the term API's contract: a + // backend that derives terms at the server (ZeroKMS v2) settles the same + // `Pending` through a request. + let (cipher, generates, retrieves) = counting_cipher().await; + let cipher = cipher.default_keyset(); + + let _term: EqualityTerm = "alice" + .encrypt_into_with_context(&cipher, nonempty!("users/email")) + .await + .unwrap(); + let _ore: OreTerm<u64> = 7u64 + .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .await + .unwrap(); + + assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); + assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 0); +} + +#[tokio::test] +async fn ciphertext_leaf_round_trips_via_decrypt_into() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let ciphertext: StackCipherText = "secret" + .to_string() + .encrypt_into_with_context(&keyset, nonempty!("users/email")) + .await + .unwrap(); + let plaintext: String = ciphertext + .decrypt_into(&cipher, nonempty!("users/email")) + .await + .unwrap(); + + assert_eq!(plaintext, "secret"); +} + +#[tokio::test] +async fn ciphertext_leaf_cannot_be_transplanted_to_another_context() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let ciphertext: StackCipherText = "secret" + .to_string() + .encrypt_into_with_context(&keyset, nonempty!("users/email")) + .await + .unwrap(); + + let transplanted: Result<String, _> = ciphertext + .decrypt_into(&cipher, nonempty!("users/name")) + .await; + assert!( + transplanted.is_err(), + "the context is bound into the AAD, so a ciphertext must not decrypt under another field's context" + ); +} + +#[tokio::test] +async fn match_leaf_supports_containment_queries() { + let generator = generator().await; + let generator = generator.default_keyset(); + + let stored: MatchTerm = "alice wonderland" + .to_string() + .encrypt_into_with_context(&generator, nonempty!("users/bio")) + .await + .unwrap(); + let query: MatchTerm = "wonder" + .to_string() + .encrypt_into_with_context(&generator, nonempty!("users/bio")) + .await + .unwrap(); + + assert!(stored.contains(&query)); +} + +/// A custom type-level match configuration. +struct SmallFilter; + +impl MatchConfig for SmallFilter { + fn options() -> MatchOptions { + MatchOptions { + m: 64, + ..Default::default() + } + } +} + +#[tokio::test] +async fn match_leaf_config_is_type_level() { + let generator = generator().await; + let generator = generator.default_keyset(); + + let term: MatchTerm<SmallFilter> = "a longer piece of text" + .to_string() + .encrypt_into_with_context(&generator, nonempty!("users/bio")) + .await + .unwrap(); + assert!(term.positions().iter().all(|&p| u32::from(p) < 64)); + + // The same text under the default config is a different (larger-filter) + // term — and a different type, so the two cannot be compared by mistake. + let default_term: MatchTerm = "a longer piece of text" + .to_string() + .encrypt_into_with_context(&generator, nonempty!("users/bio")) + .await + .unwrap(); + assert_ne!(term.positions(), default_term.positions()); +} + +#[tokio::test] +async fn ore_leaf_preserves_order_and_binds_the_context() { + let generator = generator().await; + let generator = generator.default_keyset(); + + let ten: OreTerm<u64> = 10u64 + .encrypt_into_with_context(&generator, nonempty!("users/age")) + .await + .unwrap(); + let ten_again: OreTerm<u64> = 10u64 + .encrypt_into_with_context(&generator, nonempty!("users/age")) + .await + .unwrap(); + let twenty: OreTerm<u64> = 20u64 + .encrypt_into_with_context(&generator, nonempty!("users/age")) + .await + .unwrap(); + let other_field: OreTerm<u64> = 10u64 + .encrypt_into_with_context(&generator, nonempty!("users/height")) + .await + .unwrap(); + + assert_eq!(ten, ten_again, "ORE terms must be deterministic"); + assert_eq!(ten.cmp(&twenty), Ordering::Less); + assert_ne!(ten, other_field, "per-context ORE keys must differ"); +} + +#[tokio::test] +async fn ope_leaf_compares_with_plain_byte_order() { + use stack_encrypt::sem::OpeTerm; + + let generator = generator().await; + let generator = generator.default_keyset(); + + let ten: OpeTerm<u64> = 10u64 + .encrypt_into_with_context(&generator, nonempty!("users/age")) + .await + .unwrap(); + let twenty: OpeTerm<u64> = 20u64 + .encrypt_into_with_context(&generator, nonempty!("users/age")) + .await + .unwrap(); + + assert_eq!(ten.cmp(&twenty), Ordering::Less); + assert!(ten.inner().as_ref() < twenty.inner().as_ref()); +} + +// --- Columns: batching comes from the source shape --------------------------- + +#[tokio::test] +async fn a_column_encrypts_in_one_batched_call() { + let (cipher, generates, _) = counting_cipher().await; + let cipher = cipher.default_keyset(); + + let ages: Vec<u32> = vec![29, 34, 41, 34, 57]; + let sealed: Vec<StackCipherText> = ages + .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .await + .unwrap(); + + assert_eq!(sealed.len(), 5); + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 1, + "five records must share ONE generate_keys call" + ); +} + +#[tokio::test] +async fn a_column_decrypts_in_one_batched_call() { + let (cipher, _, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); + + let ages: Vec<u32> = vec![29, 34, 41]; + let sealed: Vec<StackCipherText> = ages + .encrypt_into_with_context(&keyset, nonempty!("users/age")) + .await + .unwrap(); + + let roundtrip: Vec<u32> = sealed + .decrypt_into(&cipher, nonempty!("users/age")) + .await + .unwrap(); + + assert_eq!(roundtrip, ages); + assert_eq!( + retrieves.load(AtomicOrdering::SeqCst), + 1, + "three rows must share ONE retrieve_keys call" + ); +} + +#[tokio::test] +async fn optional_fields_encrypt_and_decrypt_structurally() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let present: Option<StackCipherText> = Some("here".to_string()) + .encrypt_into_with_context(&keyset, nonempty!("users/nickname")) + .await + .unwrap(); + let absent: Option<StackCipherText> = Option::<String>::None + .encrypt_into_with_context(&keyset, nonempty!("users/nickname")) + .await + .unwrap(); + + assert!(present.is_some()); + assert!(absent.is_none()); + + let roundtrip: Option<String> = present + .decrypt_into(&cipher, nonempty!("users/nickname")) + .await + .unwrap(); + assert_eq!(roundtrip.as_deref(), Some("here")); +} + +// --- A hand-written composite record ---------------------------------------- +// +// The shape a `#[derive(EncryptFrom)]` will emit: one impl, pendings combined +// with zip/map (never awaited), one context fanning out to every field, the +// caller seeing a single await — and a single batched call. + +/// "An encrypted `u32`, stored as its ciphertext plus an equality term and an +/// ORE term" — an EQL `integer_ord_ore`-shaped record, minus the EQL. +struct EncryptedAge { + c: StackCipherText, + hm: EqualityTerm, + ob: OreTerm<u32>, +} + +impl EncryptFrom<u32> for EncryptedAge { + type Context = CallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, u32, Self, K, Self::Context> + where + u32: 's, + { + // One context reaches all three; there is no second one to pass. + // The ciphertext seals under the AEAD half of the one context the + // terms derive under: `accepting` lets it take theirs. + stack_encrypt::target::ciphertext() + .accepting() + .zip(stack_encrypt::target::equality()) + .zip(stack_encrypt::target::ore()) + .map(|((c, hm), ob)| Self { c, hm, ob }) + } +} +impl DecryptInto<u32> for EncryptedAge { + type Context = CallerContext; + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<u32, K> { + stack_encrypt::target::open(self.c, context) + } +} + +#[tokio::test] +async fn composite_record_encrypts_every_field_from_one_source() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = generator().await; + let generator = generator.default_keyset(); + + let record: EncryptedAge = 42u32 + .encrypt_into_with_context(&keyset, nonempty!("users/age")) + .await + .unwrap(); + + // The ciphertext round-trips through the decrypt mirror. + let plaintext: u32 = record + .decrypt_into(&cipher, nonempty!("users/age")) + .await + .unwrap(); + assert_eq!(plaintext, 42); + + // Each term matches what the primitive would derive on its own, so query + // terms generated leaf-by-leaf find records encrypted as composites. + let record: EncryptedAge = 42u32 + .encrypt_into_with_context(&keyset, nonempty!("users/age")) + .await + .unwrap(); + let hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, nonempty!("users/age")) + .await + .unwrap(); + assert_eq!(record.hm, hm); + + let ob: OreTerm<u32> = 42u32 + .encrypt_into_with_context(&generator, nonempty!("users/age")) + .await + .unwrap(); + assert_eq!(record.ob, ob); +} + +#[tokio::test] +async fn a_composite_record_is_one_batched_call() { + let (cipher, generates, _) = counting_cipher().await; + let cipher = cipher.default_keyset(); + + let _record: EncryptedAge = 42u32 + .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .await + .unwrap(); + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 1, + "ciphertext + two terms must settle in ONE generate_keys call" + ); + + // A whole column of records: still one call. + let ages: Vec<u32> = vec![10, 20, 30]; + let _column: Vec<EncryptedAge> = ages + .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .await + .unwrap(); + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 2, + "a column of composite records must add ONE more call, not one per row" + ); +} + +#[tokio::test] +async fn composite_record_terms_preserve_order() { + let cipher = stack_cipher().await; + let cipher = cipher.default_keyset(); + + let ten: EncryptedAge = 10u32 + .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .await + .unwrap(); + let twenty: EncryptedAge = 20u32 + .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .await + .unwrap(); + + assert_eq!(ten.ob.cmp(&twenty.ob), Ordering::Less); +} + +// A third-party output can wrap a supported operation, but cannot replace its +// cryptographic implementation. Prefix tokenization would need a core operation. +#[derive(Debug, PartialEq, Eq)] +struct StoredEquality([u8; 32]); +impl<S> EncryptFrom<S> for StoredEquality +where + EqualityTerm: EncryptFrom<S>, +{ + type Context = <EqualityTerm as EncryptFrom<S>>::Context; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + where + S: 's, + { + <EqualityTerm as EncryptFrom<S>>::encryption().map(|term| Self(term.into_bytes())) + } +} +#[tokio::test] +async fn third_party_output_wraps_a_core_term() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let stored: StoredEquality = "alice" + .encrypt_into_with_context(&keyset, nonempty!("users/name")) + .await + .unwrap(); + let canonical: EqualityTerm = "alice" + .encrypt_into_with_context(&keyset, nonempty!("users/name")) + .await + .unwrap(); + assert_eq!( + stored.0, + canonical.into_bytes(), + "a hand-written target built on the equality operation matches the term itself" + ); +} + +// --- Guard rails -------------------------------------------------------------- + +#[tokio::test] +async fn init_pins_the_cipher_to_the_keyset_it_resolved() { + // The keyset a cipher seals data keys under is the same one whose index + // key derives its terms: `init` resolves both together, so they cannot + // diverge. (Sealing under one keyset while deriving terms under another + // would make every query silently match nothing.) + let cipher = StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher"); + + let (expected, _) = FakeDataKeySource::new() + .load_index_key(None) + .await + .expect("load index key"); + assert_eq!(cipher.default_keyset().keyset_id(), expected); +} + +#[tokio::test] +async fn an_explicit_keyset_is_honoured() { + let keyset = Uuid::from_u128(42); + let cipher = StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher"); + let explicit = cipher + .keyset(IdentifiedBy::Uuid(keyset)) + .await + .expect("select keyset"); + assert_eq!(explicit.keyset_id(), keyset); + + // Selecting one does not move the cipher's default: that is the client's, + // set by a ZeroKMS administrator, not a preference a caller can override. + assert_ne!( + cipher.default_keyset().keyset_id(), + keyset, + "default_keyset() is always the client's default" + ); + + // And its terms differ from the default keyset's: a different keyset means + // a different index key. + let default = stack_cipher().await; + let default = default.default_keyset(); + let a = explicit + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + let b = default + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + assert_ne!(a, b); +} + +#[test] +fn an_empty_context_cannot_be_built() { + // The leaves take a `NonEmpty<T>` and nothing else, so an empty context + // — one that would collapse per-field domain separation — is refused + // where the value is built, once, by vitaminc's structural check: + // `""`, `None`, `Some("")`, tuples of empties. There is no runtime path + // through a leaf for one to fail on, and `nonempty!("")` does not + // compile. + assert_eq!(NonEmpty::new("").unwrap_err(), EmptyError); + assert_eq!(NonEmpty::new(String::new()).unwrap_err(), EmptyError); + assert_eq!(NonEmpty::new(None::<&str>).unwrap_err(), EmptyError); + assert_eq!(NonEmpty::new(Some("")).unwrap_err(), EmptyError); + assert_eq!(NonEmpty::new(("", "")).unwrap_err(), EmptyError); + // A composite that still carries information is fine — and so is an + // integer, which is never empty. + assert!(NonEmpty::new(("", "email")).is_ok()); + assert!(NonEmpty::new(Some("users/email")).is_ok()); + let _: NonEmpty<u64> = 0u64.into(); +} + +#[tokio::test] +async fn wrapped_and_extended_contexts_bind_like_plain_ones() { + // vitaminc blanket-implements the context traits for `Option` and + // tuples, and `NonEmpty::with` extends a proven head with any tail: all + // of them are contexts a leaf takes, and all of them bind. + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let sealed: StackCipherText = "secret" + .to_string() + .encrypt_into_with_context(&keyset, NonEmpty::new(Some("users/email")).unwrap()) + .await + .unwrap(); + let opened: String = sealed + .decrypt_into(&cipher, NonEmpty::new(Some("users/email")).unwrap()) + .await + .unwrap(); + assert_eq!(opened, "secret"); + + // Extended with a row id: opens under the same pair, and only there. + // (Against the fake key source the AEAD refuses; ZeroKMS refuses the + // key retrieval under the other descriptor first, as `Error::Kms`.) + let sealed: StackCipherText = "secret" + .to_string() + .encrypt_into_with_context(&keyset, nonempty!("users/email").with(42u64)) + .await + .unwrap(); + let wrong_row: Result<String, _> = sealed + .decrypt_into(&cipher, nonempty!("users/email").with(43u64)) + .await; + assert!(matches!(wrong_row, Err(Error::Aead))); + let sealed: StackCipherText = "secret" + .to_string() + .encrypt_into_with_context(&keyset, nonempty!("users/email").with(42u64)) + .await + .unwrap(); + let no_row: Result<String, _> = sealed.decrypt_into(&cipher, nonempty!("users/email")).await; + assert!(matches!(no_row, Err(Error::Aead))); + + // The pair encodes as the bare tuple would: a term under the extended + // context equals one under the plain pair, so a query site need not + // hold a `NonEmpty` head to probe. + let extended: EqualityTerm = "alice" + .encrypt_into_with_context(&keyset, nonempty!("users/email").with(42u64)) + .await + .unwrap(); + let plain: EqualityTerm = "alice" + .encrypt_into_with_context(&keyset, NonEmpty::new(("users/email", 42u64)).unwrap()) + .await + .unwrap(); + assert_eq!(extended, plain); + let unextended: EqualityTerm = "alice" + .encrypt_into_with_context(&keyset, nonempty!("users/email")) + .await + .unwrap(); + assert_ne!(extended, unextended); +} + +#[tokio::test] +async fn a_bare_integer_is_a_context() { + // An integer is never empty, so it converts into a `NonEmpty` on its own + // and the sugar takes it bare. + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let sealed: StackCipherText = "secret" + .to_string() + .encrypt_into_with_context(&keyset, 42u64) + .await + .unwrap(); + let opened: String = sealed + .decrypt_into(&cipher, NonEmpty::from(42u64)) + .await + .unwrap(); + assert_eq!(opened, "secret"); + let term: EqualityTerm = "alice" + .encrypt_into_with_context(&keyset, 7u32) + .await + .unwrap(); + let other: EqualityTerm = "alice" + .encrypt_into_with_context(&keyset, 8u32) + .await + .unwrap(); + assert_ne!(term, other); +} + +#[tokio::test] +async fn containers_pass_the_context_through_to_their_leaves() { + // `Vec` and `Option` hand the context on untouched, and so hand on the + // obligation: a column of leaves is `EncryptFrom<_, _, Ctx>` only for a + // `NonEmpty<_>` (`tests/ui/leaf_without_context.rs`), a column of + // derived rows — records whose fields carry their own contexts — for + // `()` as well. An empty container derives nothing either way. + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let none: Option<StackCipherText> = None::<String> + .encrypt_into_with_context(&keyset, nonempty!("users/x")) + .await + .unwrap(); + assert!(none.is_none()); + let empty: Vec<StackCipherText> = Vec::<String>::new() + .encrypt_into_with_context(&keyset, nonempty!("users/x")) + .await + .unwrap(); + assert!(empty.is_empty()); + let empty: Vec<String> = Vec::<StackCipherText>::new() + .decrypt_into(&cipher, nonempty!("users/x")) + .await + .unwrap(); + assert!(empty.is_empty()); +} + +/// A stored target whose declaration is refused before any key is named — +/// the shape of a derived record whose stored context fails validation. +/// Counts how many times it was asked to declare. +struct Refused(Arc<AtomicUsize>); +impl DecryptInto<u32> for Refused { + type Context = CallerContext; + fn decryption<K: 'static>(self, _: Self::Context) -> Decryption<u32, K> { + self.0.fetch_add(1, AtomicOrdering::SeqCst); + Decryption::failed(Error::NotOpened) + } +} + +#[tokio::test] +async fn a_column_stops_declaring_at_the_first_refused_row() { + let (cipher, _, retrieves) = counting_cipher().await; + let declared = Arc::new(AtomicUsize::new(0)); + let column: Vec<Refused> = (0..1_000).map(|_| Refused(Arc::clone(&declared))).collect(); + + // `Vec<T>` declares its rows lazily and the collection stops at the + // first refusal: the rows after it are never asked, and nothing is + // retrieved. + let result: Result<Vec<u32>, _> = cipher + .decrypt_as(column, CallerContext::from(nonempty!("users/age"))) + .await; + assert!(matches!(result, Err(Error::NotOpened)), "{result:?}"); + assert_eq!(declared.load(AtomicOrdering::SeqCst), 1); + assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 0); +} + +#[tokio::test] +async fn a_failed_field_fails_the_record_before_any_kms_call() { + // One failed field must not cause the record's other fields to mint + // data keys that are then thrown away. A match term over text that + // yields no tokens fails during the synchronous build, before any I/O. + let (cipher, generates, _) = counting_cipher().await; + let cipher = cipher.default_keyset(); + let b = "b".to_string(); + + let zipped = MatchTerm::<SmallFilter>::encrypt_from(&"", &cipher, nonempty!("users/x")) + .zip(StackCipherText::encrypt_from( + &b, + &cipher, + nonempty!("users/x"), + )) + .await; + assert!(matches!(zipped, Err(Error::Term(_)))); + + let column = Pending::all( + &cipher, + vec![ + StackCipherText::encrypt_from(&b, &cipher, nonempty!("users/x")), + Pending::failed(&cipher, Error::NotOpened), + ], + ) + .await; + assert!(matches!(column, Err(Error::NotOpened))); + + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 0, + "a failed sibling must drop the batch, not dispatch it" + ); +} + +/// Which `StackCipher` *value* a pending was built through is not part of +/// the merge rule — the keyset is. Two ciphers over the same client config +/// resolve the same default keyset, so their pendings merge and settle as +/// one batch, through the cipher the assembly dispatches on. (Before the +/// multi-keyset `StackCipher` this was pointer equality on the cipher, and +/// refused the pair.) +#[tokio::test] +async fn pendings_from_two_ciphers_over_the_same_keyset_merge() { + let (cipher_a, generates, _) = counting_cipher().await; + let cipher_a = cipher_a.default_keyset(); + let cipher_b = counting_cipher().await.0; + let cipher_b = cipher_b.default_keyset(); + assert_eq!( + cipher_a.keyset_id(), + cipher_b.keyset_id(), + "two ciphers over the same client config share a default keyset" + ); + let v = "v".to_string(); + let w = "w".to_string(); + + let zipped = StackCipherText::encrypt_from(&v, &cipher_a, nonempty!("users/x")) + .zip(StackCipherText::encrypt_from( + &w, + &cipher_b, + nonempty!("users/x"), + )) + .await; + assert!( + zipped.is_ok(), + "two ciphers over the same keyset must merge: {:?}", + zipped.err() + ); + + let column = Pending::all( + &cipher_a, + vec![ + StackCipherText::encrypt_from(&v, &cipher_a, nonempty!("users/x")), + StackCipherText::encrypt_from(&w, &cipher_b, nonempty!("users/x")), + ], + ) + .await; + assert!( + column.is_ok(), + "a column drawn from two ciphers over one keyset must merge: {:?}", + column.err() + ); + + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 2, + "each merged assembly settles as exactly one generate_keys call" + ); +} + +/// The rule the cipher check gave way to: two *keysets* still refuse to +/// merge, whichever ciphers they came from, and with no I/O. +#[tokio::test] +async fn pendings_from_two_ciphers_over_different_keysets_refuse_to_merge() { + let (cipher_a, generates, _) = counting_cipher().await; + let acme = cipher_a + .keyset(IdentifiedBy::Name("acme".to_string().into())) + .await + .unwrap(); + let cipher_b = counting_cipher().await.0; + let globex = cipher_b + .keyset(IdentifiedBy::Name("globex".to_string().into())) + .await + .unwrap(); + let v = "v".to_string(); + let w = "w".to_string(); + + let zipped = StackCipherText::encrypt_from(&v, &acme, nonempty!("users/x")) + .zip(StackCipherText::encrypt_from( + &w, + &globex, + nonempty!("users/x"), + )) + .await; + assert!( + matches!(zipped, Err(Error::KeysetMismatch { left, right }) + if left == acme.keyset_id() && right == globex.keyset_id()), + "expected KeysetMismatch, got: {zipped:?}" + ); + + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 0, + "a mismatched merge must be refused before any key is minted" + ); +} + +#[tokio::test] +async fn an_overdrawing_fulfilment_is_a_response_shape_error() { + // A fulfilment is scoped to exactly the responses its requests asked for: + // drawing more must fail loudly, never consume a sibling's responses. + let cipher = stack_cipher().await; + let cipher = cipher.default_keyset(); + + let pending: Pending<'_, (), _> = Pending::request( + &cipher, + vec![Request::generate_data_key(nonempty!("t"))], + |responses| { + responses.next_generated_key()?; + responses.next_generated_key()?; // one more than requested + Ok(()) + }, + ); + + assert!(matches!(pending.await, Err(Error::ResponseShape))); +} + +// Terms rebuilt from persisted parts must behave like freshly generated ones. +#[tokio::test] +async fn terms_rehydrate_from_persisted_parts() { + let generator = generator().await; + let generator = generator.default_keyset(); + + let eq: EqualityTerm = "alice" + .encrypt_into_with_context(&generator, nonempty!("users/email")) + .await + .unwrap(); + let rehydrated = EqualityTerm::from_bytes(eq.clone().into_bytes()); + assert_eq!(eq, rehydrated); + + let stored: MatchTerm = "alice wonderland" + .to_string() + .encrypt_into_with_context(&generator, nonempty!("users/bio")) + .await + .unwrap(); + let query: MatchTerm = "wonder" + .to_string() + .encrypt_into_with_context(&generator, nonempty!("users/bio")) + .await + .unwrap(); + // Rehydrate from unsorted positions: from_positions normalises (and + // range-checks against the config's filter size). + let mut positions = stored.clone().into_positions(); + positions.reverse(); + let rehydrated: MatchTerm = MatchTerm::from_positions(positions).unwrap(); + assert_eq!(stored, rehydrated); + assert!(rehydrated.contains(&query)); +} + +// The pending futures are `Send` on native targets, so target-directed +// encryption can hop threads (e.g. `tokio::spawn`). +#[tokio::test] +async fn pending_futures_are_send() { + let generator = generator().await; + + let handle = tokio::spawn(async move { + let generator = generator.default_keyset(); + let term: EqualityTerm = "alice" + .encrypt_into_with_context(&generator, nonempty!("users/email")) + .await + .unwrap(); + term + }); + + let _term = handle.await.unwrap(); +} + +// --- The cipher-directed API is the same path ------------------------------ + +#[tokio::test] +async fn cipher_directed_encrypt_and_decrypt_are_one_batched_call_each() { + let (cipher, generates, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); + + let names: Vec<String> = ["ada", "grace", "edsger", "barbara"] + .into_iter() + .map(String::from) + .collect(); + let ct = keyset.encrypt(names.clone(), "users/name").await.unwrap(); + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 1, + "cipher.encrypt of a four-leaf value must be ONE generate_keys call" + ); + + let roundtrip: Vec<String> = cipher.decrypt(ct, "users/name").await.unwrap(); + assert_eq!(roundtrip, names); + assert_eq!( + retrieves.load(AtomicOrdering::SeqCst), + 1, + "cipher.decrypt of a four-leaf value must be ONE retrieve_keys call" + ); +} + +#[tokio::test] +async fn cipher_directed_and_target_directed_ciphertexts_are_interchangeable() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let value = vec!["one".to_string(), "two".to_string(), "three".to_string()]; + + // Sealed by the cipher-directed API, opened by the target-directed one. + let ct = keyset.encrypt(value.clone(), "users/tags").await.unwrap(); + let via_target: Vec<String> = ct + .decrypt_into(&cipher, nonempty!("users/tags")) + .await + .unwrap(); + assert_eq!(via_target, value); + + // Sealed by the target-directed API, opened by the cipher-directed one. + let ct: StackCipherText = value + .encrypt_into_with_context(&keyset, nonempty!("users/tags")) + .await + .unwrap(); + let via_cipher: Vec<String> = cipher.decrypt(ct, "users/tags").await.unwrap(); + assert_eq!(via_cipher, value); +} + +#[tokio::test] +async fn cipher_directed_decrypt_rejects_a_transplanted_ciphertext() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let ct: StackCipherText = "secret" + .to_string() + .encrypt_into_with_context(&keyset, nonempty!("users/email")) + .await + .unwrap(); + let result: Result<String, Error> = cipher.decrypt(ct, "users/name").await; + assert!( + matches!(result, Err(Error::Aead)), + "a target-sealed leaf must not open under another context via the cipher API \ + (the fake key source ignores descriptors; ZeroKMS would refuse the retrieve)" + ); +} + +// --- One context per target, threaded through a hand-written tree (ADR-0004) + +/// A hand-written composite with a context of its own: the tree is given it +/// once, with `under`, and the caller's context — if any — extends it. The +/// ciphertext and the term beside it are under the same one by +/// construction; there is no second context to hand either. +struct OwnedEmail { + c: StackCipherText, + hm: EqualityTerm, +} + +impl EncryptFrom<String> for OwnedEmail { + type Context = DeclaredContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + where + String: 's, + { + ciphertext() + .accepting() + .zip(equality()) + .under(nonempty!("users/email")) + .map(|(c, hm)| Self { c, hm }) + } +} + +#[tokio::test] +async fn under_gives_a_subtree_its_own_context_which_the_callers_extends() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = generator().await; + let generator = generator.default_keyset(); + let email = "alice@example.com".to_string(); + + // Under `()`: the own context as it is, for the ciphertext and the term + // alike. + let record: OwnedEmail = email.encrypt_into(&keyset).await.unwrap(); + let probe: EqualityTerm = email + .encrypt_into_with_context(&generator, nonempty!("users/email")) + .await + .unwrap(); + assert_eq!(record.hm, probe); + let opened: String = record + .c + .decrypt_into(&cipher, nonempty!("users/email")) + .await + .unwrap(); + assert_eq!(opened, email); + + // Under a caller's context: the own context extended by it, for both. + let record: OwnedEmail = email + .encrypt_into_with_context(&keyset, 7u64) + .await + .unwrap(); + let probe: EqualityTerm = email + .encrypt_into_with_context(&generator, nonempty!("users/email").with(7u64)) + .await + .unwrap(); + assert_eq!(record.hm, probe); + let opened: String = record + .c + .decrypt_into(&cipher, nonempty!("users/email").with(7u64)) + .await + .unwrap(); + assert_eq!(opened, email); +} + +/// A composite whose ciphertext has a context of its own and whose term is +/// derived under the caller's: `extend` gives the one subtree its own, and +/// the caller's stays required, because the other subtree needs it. +struct ShadowedEmail { + shadow: StackCipherText, + hm: EqualityTerm, +} + +impl EncryptFrom<String> for ShadowedEmail { + type Context = CallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + where + String: 's, + { + ciphertext() + .extend(nonempty!("users/shadow")) + .zip(equality()) + .map(|(shadow, hm)| Self { shadow, hm }) + } +} + +#[tokio::test] +async fn extend_gives_a_subtree_its_own_context_and_still_requires_the_callers() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = generator().await; + let generator = generator.default_keyset(); + let email = "alice@example.com".to_string(); + + let record: ShadowedEmail = email + .encrypt_into_with_context(&keyset, nonempty!("users")) + .await + .unwrap(); + // The term is under the caller's context as it is ... + let probe: EqualityTerm = email + .encrypt_into_with_context(&generator, nonempty!("users")) + .await + .unwrap(); + assert_eq!(record.hm, probe); + // ... and the ciphertext under its own, extended by the caller's. + let opened: String = record + .shadow + .decrypt_into(&cipher, nonempty!("users/shadow").with(nonempty!("users"))) + .await + .unwrap(); + assert_eq!(opened, email); +} + +/// A composite declaring the context its *caller* supplies — a tenant id — +/// while its operations need a `CallerContext`: `accepting` converts once, +/// at the root, and `map_with_context` hands the output what the tree ran +/// under, so the record can keep it. +struct TenantEmail { + hm: EqualityTerm, + tenant: u64, +} + +impl EncryptFrom<String> for TenantEmail { + type Context = NonEmpty<u64>; + fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + where + String: 's, + { + equality() + .accepting::<NonEmpty<u64>>() + .map_with_context(|hm, tenant| Self { + hm, + tenant: tenant.into_inner(), + }) + } +} + +#[tokio::test] +async fn accepting_converts_the_declared_context_once_and_map_with_context_keeps_it() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = generator().await; + let generator = generator.default_keyset(); + let email = "alice@example.com".to_string(); + + let record: TenantEmail = email + .encrypt_into_with_context(&keyset, NonEmpty::from(7u64)) + .await + .unwrap(); + assert_eq!(record.tenant, 7); + let probe: EqualityTerm = email + .encrypt_into_with_context(&generator, 7u64) + .await + .unwrap(); + assert_eq!(record.hm, probe); +} diff --git a/packages/stack-encrypt/tests/term_bytes.rs b/packages/stack-encrypt/tests/term_bytes.rs new file mode 100644 index 000000000..ebe5f04cb --- /dev/null +++ b/packages/stack-encrypt/tests/term_bytes.rs @@ -0,0 +1,139 @@ +//! Byte-level pins for the SEM term derivations. +//! +//! These lock the exact bytes a term derives from: the PAE domain label, the +//! framing of the context, and the order the pieces go in. A change to any of +//! them changes every stored term — and because terms are compared for +//! equality server-side, the failure mode is not an error but a query that +//! silently stops matching. Breaking one of these tests means the derivation +//! moved, and the `/v1` suffix in the domain labels has to move with it. +//! +//! Keyed by `FakeDataKeySource`'s deterministic index key, so the expected +//! bytes are stable without ZeroKMS. +//! +//! The pins moved once without the derivation moving: vitaminc 0.5 changed +//! the canonical encoding of a context (typed leaves, one encoding for the +//! AEAD and the PRF), so the bytes under every label changed while the +//! labels and framing here did not. That was a prerelease wire break, taken +//! deliberately (CIP-4036); the `/v1` suffixes stayed because nothing of +//! this crate's own moved. + +use stack_encrypt::nonempty; +use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions}; +use stack_encrypt::StackCipher; +use stack_kms::FakeDataKeySource; + +async fn cipher() -> StackCipher<FakeDataKeySource> { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher") +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[tokio::test] +async fn equality_term_bytes_are_pinned() { + let cipher = cipher().await; + let term = cipher + .default_keyset() + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + + assert_eq!( + hex(term.as_bytes()), + "c101ef066547cb33003c352dcd02a42bfe4a24e2d0609caca0189d9f1af8262a" + ); +} + +#[tokio::test] +async fn match_term_positions_are_pinned() { + let cipher = cipher().await; + let term = cipher + .default_keyset() + .match_terms::<DefaultMatch>("alice smith", nonempty!("users/name")) + .await + .unwrap(); + + // Pins the tokenizer, the per-token PRF framing, and the Bloom folding + // together: any of the three moving changes this set. + assert_eq!( + term.positions(), + [ + 4, 5, 10, 13, 14, 30, 39, 53, 56, 61, 85, 94, 95, 100, 111, 125, 127, 156, 173, 188, + 189, 202, 208, 224, 229, 239 + ] + ); +} + +#[tokio::test] +async fn ore_term_bytes_are_pinned() { + // The ORE key is a PRF of the descriptor, so this pins the key derivation + // as much as the CLLW encryption. + let cipher = cipher().await; + let term = cipher + .default_keyset() + .ore_term(42u32, nonempty!("users/age")) + .await + .unwrap(); + + assert_eq!( + hex(term.as_ref()), + "1ae5f8558dc2d7dddd6c5b714e9d285586a1b8390d9140421e78906cba1bd651" + ); +} + +#[tokio::test] +async fn ope_term_bytes_are_pinned() { + // Distinct from the ORE pin above under the same descriptor: the two + // schemes derive their keys under different domains and must never share + // one (OPE ciphertexts are encrypt-only). + let cipher = cipher().await; + let term = cipher + .default_keyset() + .ope_term(42u32, nonempty!("users/age")) + .await + .unwrap(); + + assert_eq!( + hex(term.as_ref()), + "00837615a1ea2fdcbebf7efe34cf4d2ee432c7eeff84fbd72e1bf05efa2338033c" + ); +} + +/// A filter wider than 256 bits: the default's mask keeps only the low byte +/// of each 2-byte slice, so the pin above cannot see which byte fills the +/// high half. Here every position uses both. +struct WideMatch; + +impl MatchConfig for WideMatch { + fn options() -> MatchOptions { + MatchOptions { + m: 65536, + ..Default::default() + } + } +} + +#[tokio::test] +async fn match_term_positions_are_pinned_for_a_wide_filter() { + let cipher = cipher().await; + let term = cipher + .default_keyset() + .match_terms::<WideMatch>("alice smith", nonempty!("users/name")) + .await + .unwrap(); + + assert_eq!( + term.positions(), + [ + 2287, 2398, 2404, 2829, 5150, 5181, 5293, 9255, 11964, 14175, 16080, 24350, 25354, + 28362, 31748, 33647, 35845, 39141, 39480, 41998, 42621, 45365, 50303, 60896, 64668, + 64957, 65365 + ], + "positions for a 65536-bit filter are frozen: both bytes of each slice are in play" + ); +} diff --git a/packages/stack-encrypt/tests/transcode.rs b/packages/stack-encrypt/tests/transcode.rs new file mode 100644 index 000000000..23c011cef --- /dev/null +++ b/packages/stack-encrypt/tests/transcode.rs @@ -0,0 +1,535 @@ +//! A consumer outside the crate: typed EQL-shaped records, native readers, and +//! cross-opening through the canonical cipher path. No plaintext Serde fallback. +mod common; +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::transcode::{MapReader, Reader, SequenceReader, Transcode, Visitor}; +use stack_encrypt::target::{self, CallerContext, ExpectedContext}; +use stack_encrypt::{ + nonempty, Cipher, CipherText, ContextPiece, DecryptField, DecryptInto, Decryptable, Decryption, + Encrypt, EncryptFrom, Encryption, Error, IntoAad, IntoContext, MaybeEmpty, NonEmpty, + SealedValue, StackCipherText, +}; +use std::sync::atomic::Ordering; + +#[derive(Clone, Debug, PartialEq)] +struct Identifier { + table: String, + column: String, +} +impl Identifier { + fn email() -> NonEmpty<Self> { + NonEmpty::new(Self { + table: "users".into(), + column: "email".into(), + }) + .unwrap() + } +} +impl MaybeEmpty for Identifier { + fn is_empty(&self) -> bool { + self.table.is_empty() || self.column.is_empty() + } +} +impl<'a> IntoContext<'a> for Identifier { + fn into_context(self) -> ContextPiece<'a> { + (self.table, self.column).into_context() + } +} + +struct StoredLeaf(Vec<u8>); +struct LeafVisitor; +impl Visitor for LeafVisitor { + type Value = StoredLeaf; + fn sealed(self, leaf: SealedValue) -> Result<StoredLeaf, Error> { + Ok(StoredLeaf(leaf.to_bytes())) + } +} +impl Transcode for StoredLeaf { + type Visitor = LeafVisitor; + fn visitor() -> LeafVisitor { + LeafVisitor + } +} +impl<S: Encrypt + Clone> EncryptFrom<S> for StoredLeaf { + type Context = CallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + where + S: 's, + { + target::ciphertext().accepting().transcode() + } +} +impl<P: stack_encrypt::Decrypt<'static> + 'static> DecryptInto<P> for StoredLeaf { + type Context = CallerContext; + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<P, K> { + match SealedValue::from_bytes(&self.0) { + Ok(leaf) => target::open(CipherText::Single(leaf), context), + Err(error) => Decryption::failed(Error::Other(Box::new(error))), + } + } +} +impl Decryptable for StoredLeaf { + const DECRYPTABLE: bool = true; +} +impl<P, Ctx> DecryptField<P, Ctx> for StoredLeaf +where + Self: DecryptInto<P>, + Ctx: Into<<Self as DecryptInto<P>>::Context>, +{ + fn decryption_field<K: 'static>(self, ctx: Ctx) -> Option<Decryption<P, K>> { + Some(self.decryption(ctx.into())) + } +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = String)] +struct TextEq { + #[stash(context_field)] + i: Identifier, + c: StoredLeaf, + hm: EqualityTerm, + #[stash(default = 3)] + v: u8, +} + +fn other_column() -> NonEmpty<Identifier> { + NonEmpty::new(Identifier { + table: "users".into(), + column: "other".into(), + }) + .unwrap() +} + +#[tokio::test] +async fn stored_identifier_supplies_every_operations_context() { + let (cipher, generates, _) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let email = "alice@example.com".to_owned(); + let record: TextEq = keyset + .encrypt_as(&email, Identifier::email()) + .await + .unwrap(); + assert_eq!( + record.i, + Identifier::email().into_inner(), + "the identifier is stored in the record as given" + ); + assert_eq!(record.v, 3, "a defaulted field is filled, not derived"); + assert_eq!( + generates.load(Ordering::SeqCst), + 1, + "one ciphertext leaf means one data key, in one batch" + ); + let probe = keyset + .equality_term(email.clone(), Identifier::email()) + .await + .unwrap(); + assert_eq!( + record.hm, probe, + "the term is derived under the stored identifier, so a probe under it matches" + ); + // Storage envelope names c/hm add no extra AAD; the canonical path opens it. + let opened: String = cipher + .decrypt( + CipherText::Single(SealedValue::from_bytes(&record.c.0).unwrap()), + Identifier::email(), + ) + .await + .unwrap(); + assert_eq!( + opened, email, + "the canonical path opens a leaf the record sealed under the identifier alone" + ); +} + +#[tokio::test] +async fn canonical_ciphertext_opens_through_the_record() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let email = "alice@example.com".to_owned(); + let canonical = keyset + .encrypt(email.clone(), Identifier::email()) + .await + .unwrap(); + let probe = keyset + .equality_term(email.clone(), Identifier::email()) + .await + .unwrap(); + let record = TextEq { + i: Identifier::email().into_inner(), + c: canonical.read(LeafVisitor).unwrap(), + hm: probe, + v: 3, + }; + let opened: String = cipher + .decrypt_as(record, ExpectedContext::default()) + .await + .unwrap(); + assert_eq!( + opened, email, + "a record assembled from canonical output opens under its stored identifier" + ); +} + +#[tokio::test] +async fn expected_identifier_is_checked_before_any_key_is_retrieved() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let email = "alice@example.com".to_owned(); + let record: TextEq = keyset + .encrypt_as(&email, Identifier::email()) + .await + .unwrap(); + let before = retrieves.load(Ordering::SeqCst); + let result = cipher + .decrypt_as::<String, _>(record, other_column().into()) + .await; + assert!( + matches!(result, Err(Error::ContextMismatch { .. })), + "a stored identifier that differs from the expected one is a mismatch, got {result:?}" + ); + assert_eq!( + retrieves.load(Ordering::SeqCst), + before, + "the mismatch is refused before ZeroKMS is asked for a key" + ); +} + +#[tokio::test] +async fn empty_stored_identifier_is_refused_before_any_key_is_retrieved() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let email = "alice@example.com".to_owned(); + let mut record: TextEq = keyset + .encrypt_as(&email, Identifier::email()) + .await + .unwrap(); + record.i.column.clear(); + let before = retrieves.load(Ordering::SeqCst); + let result = cipher + .decrypt_as::<String, _>(record, Default::default()) + .await; + assert!( + result.is_err(), + "a stored identifier is data, not a proof: an empty one fails validation" + ); + assert_eq!( + retrieves.load(Ordering::SeqCst), + before, + "validation runs before ZeroKMS is asked for a key" + ); +} + +#[derive(Clone)] +struct WithoutSerde(String); +impl Encrypt for WithoutSerde { + fn encrypt_with_aad<'a, C: Cipher, A: IntoAad<'a>>( + self, + cipher: C, + aad: A, + ) -> Result<C::Ok, C::Error> { + self.0.encrypt_with_aad(cipher, aad) + } +} +#[tokio::test] +async fn ciphertext_uses_plaintexts_native_contract_without_serde() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let result: StoredLeaf = keyset + .encrypt_as(&WithoutSerde("native".into()), nonempty!("value").into()) + .await + .unwrap(); + let opened: String = cipher + .decrypt( + CipherText::Single(SealedValue::from_bytes(&result.0).unwrap()), + nonempty!("value"), + ) + .await + .unwrap(); + assert_eq!( + opened, "native", + "a plaintext without Serde seals through its own Encrypt impl and opens canonically" + ); + let text = String::from("borrowed"); + let result: StoredLeaf = keyset + .encrypt_as(&text.as_str(), nonempty!("value").into()) + .await + .unwrap(); + let opened: String = cipher + .decrypt( + CipherText::Single(SealedValue::from_bytes(&result.0).unwrap()), + nonempty!("value"), + ) + .await + .unwrap(); + assert_eq!(opened, text, "a borrowed plaintext seals the same way"); +} + +#[tokio::test] +async fn scalar_destination_refuses_a_sequence() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + // A scalar destination must refuse a sequence rather than flatten or serialize it. + let result = keyset + .encrypt_as::<_, StoredLeaf>(&vec![1u32, 2], nonempty!("value").into()) + .await; + assert!( + matches!(result, Err(Error::UnsupportedShape)), + "a visitor that only takes `sealed` refuses a sequence by type, got {:?}", + result.err() + ); +} + +// A target whose declaration carries every context it needs asks its caller +// for none: `Context = ()`. +struct FixedLeaf(StoredLeaf); +impl<S: Encrypt + Clone> EncryptFrom<S> for FixedLeaf { + type Context = (); + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> + where + S: 's, + { + // The declaration names its own context with `under`; `()` then + // satisfies the `DeclaredContext` that leaves. + target::ciphertext() + .transcode() + .map(Self) + .under(nonempty!("fixed/leaf")) + .accepting() + } +} +impl<P: stack_encrypt::Decrypt<'static> + 'static> DecryptInto<P> for FixedLeaf { + type Context = (); + fn decryption<K: 'static>(self, (): ()) -> Decryption<P, K> { + self.0.decryption(nonempty!("fixed/leaf").into()) + } +} + +#[tokio::test] +async fn unit_context_target_supplies_its_own_context() { + use stack_encrypt::EncryptInto; + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let text = String::from("fixed"); + let stored: FixedLeaf = text.encrypt_into(&keyset).await.unwrap(); + let opened: String = cipher + .decrypt( + CipherText::Single(SealedValue::from_bytes(&stored.0 .0).unwrap()), + nonempty!("fixed/leaf"), + ) + .await + .unwrap(); + assert_eq!( + opened, text, + "the leaf was sealed under the context the declaration named, not one the caller gave" + ); + let stored: FixedLeaf = keyset.encrypt_as(&text, ()).await.unwrap(); + let opened: String = cipher.decrypt_as(stored, ()).await.unwrap(); + assert_eq!( + opened, text, + "and it opens back through the declaration alone" + ); +} + +// An illustrative final storage format. Each native child is consumed directly +// into its destination; no intermediate universal tree or byte buffer is built. +enum Stored { + Value(SealedValue), + List(Vec<Stored>), + Object(Vec<(String, Stored)>), + Absent(SealedValue), + EmptyList(SealedValue), + EmptyObject(SealedValue), + Metadata(stack_encrypt::BoxedPassthrough), +} +struct TreeVisitor; +impl Visitor for TreeVisitor { + type Value = Stored; + fn sealed(self, leaf: SealedValue) -> Result<Stored, Error> { + Ok(Stored::Value(leaf)) + } + fn sequence<R: SequenceReader>(self, mut reader: R) -> Result<Stored, Error> { + let mut output = Vec::with_capacity(reader.remaining()); + while let Some(child) = reader.next() { + output.push(child.read(TreeVisitor)?); + } + Ok(Stored::List(output)) + } + fn map<R: MapReader>(self, mut reader: R) -> Result<Stored, Error> { + let mut output = Vec::with_capacity(reader.remaining()); + while let Some((key, child)) = reader.next() { + output.push((key, child.read(TreeVisitor)?)); + } + Ok(Stored::Object(output)) + } + fn absent(self, marker: SealedValue) -> Result<Stored, Error> { + Ok(Stored::Absent(marker)) + } + fn empty_sequence(self, marker: SealedValue) -> Result<Stored, Error> { + Ok(Stored::EmptyList(marker)) + } + fn empty_map(self, marker: SealedValue) -> Result<Stored, Error> { + Ok(Stored::EmptyObject(marker)) + } + fn passthrough(self, value: stack_encrypt::BoxedPassthrough) -> Result<Stored, Error> { + Ok(Stored::Metadata(value)) + } +} +impl Stored { + fn native(self) -> StackCipherText { + match self { + Self::Value(v) => CipherText::Single(v), + Self::List(v) => CipherText::Sequence(v.into_iter().map(Self::native).collect()), + Self::Object(v) => { + CipherText::Map(v.into_iter().map(|(k, v)| (k, v.native())).collect()) + } + Self::Absent(v) => CipherText::None(v), + Self::EmptyList(v) => CipherText::EmptySequence(v), + Self::EmptyObject(v) => CipherText::EmptyMap(v), + Self::Metadata(v) => CipherText::Passthrough(v), + } + } +} + +#[test] +fn native_readers_report_the_number_of_remaining_entries() { + struct LengthVisitor; + + impl Visitor for LengthVisitor { + type Value = usize; + + fn sequence<R: SequenceReader>(self, mut reader: R) -> Result<usize, Error> { + let length = reader.remaining(); + assert_eq!( + length, 3, + "sequence should initially report all three entries" + ); + for consumed in 1..=length { + assert!( + reader.next().is_some(), + "sequence entry {consumed} should exist" + ); + assert_eq!( + reader.remaining(), + length - consumed, + "sequence should report its remaining length after entry {consumed}" + ); + } + assert!( + reader.next().is_none(), + "sequence should end after three entries" + ); + Ok(length) + } + + fn map<R: MapReader>(self, mut reader: R) -> Result<usize, Error> { + let length = reader.remaining(); + assert_eq!(length, 3, "map should initially report all three entries"); + for consumed in 1..=length { + assert!(reader.next().is_some(), "map entry {consumed} should exist"); + assert_eq!( + reader.remaining(), + length - consumed, + "map should report its remaining length after entry {consumed}" + ); + } + assert!( + reader.next().is_none(), + "map should end after three entries" + ); + Ok(length) + } + } + + let value = || CipherText::Passthrough(Box::new(7_u32) as stack_encrypt::BoxedPassthrough); + let sequence: StackCipherText = CipherText::Sequence(vec![value(), value(), value()]); + assert_eq!( + sequence.read(LengthVisitor).unwrap(), + 3, + "sequence should yield three entries" + ); + + let map: StackCipherText = CipherText::Map(vec![ + ("first".into(), value()), + ("second".into(), value()), + ("third".into(), value()), + ]); + assert_eq!( + map.read(LengthVisitor).unwrap(), + 3, + "map should yield three entries" + ); +} + +#[tokio::test] +async fn native_readers_preserve_map_keys_and_authenticated_markers() { + use std::collections::HashMap; + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let value: HashMap<String, Vec<Option<String>>> = HashMap::from([ + ("entries".into(), vec![Some("secret".into()), None]), + ("empty".into(), vec![]), + ]); + let tree = keyset + .encrypt(value.clone(), nonempty!("document")) + .await + .unwrap(); + let stored = tree.read(TreeVisitor).unwrap(); + let opened: HashMap<String, Vec<Option<String>>> = cipher + .decrypt(stored.native(), nonempty!("document")) + .await + .unwrap(); + assert_eq!( + opened, value, + "a tree read into a destination and back opens as the original value" + ); + assert_eq!( + generates.load(Ordering::SeqCst), + 1, + "the whole document sealed under one data key" + ); + assert_eq!( + retrieves.load(Ordering::SeqCst), + 1, + "and opened with one retrieval" + ); + // Transcoding keeps the authentication: changing a cryptographic map key fails. + let tree = keyset.encrypt(value, nonempty!("document")).await.unwrap(); + let Stored::Object(mut entries) = tree.read(TreeVisitor).unwrap() else { + panic!("object") + }; + entries[0].0 = "renamed".into(); + let opened: Result<HashMap<String, Vec<Option<String>>>, _> = cipher + .decrypt(Stored::Object(entries).native(), nonempty!("document")) + .await; + assert!( + opened.is_err(), + "a map key is part of its entry's authenticated context: renaming it fails to open" + ); + let tree = keyset + .encrypt(HashMap::<String, String>::new(), nonempty!("document")) + .await + .unwrap(); + let Stored::EmptyObject(marker) = tree.read(TreeVisitor).unwrap() else { + panic!("empty map marker") + }; + // A marker is sealed data, not an unauthenticated empty container. + let opened: Result<HashMap<String, String>, _> = cipher + .decrypt(CipherText::EmptyMap(marker), nonempty!("wrong")) + .await; + assert!( + opened.is_err(), + "an empty-map marker is sealed under its context, not an unauthenticated empty container" + ); + let stored = CipherText::Passthrough(Box::new(17u32) as stack_encrypt::BoxedPassthrough) + .read(TreeVisitor) + .unwrap(); + let Stored::Metadata(value) = stored else { + panic!("metadata") + }; + assert_eq!( + *value.downcast::<u32>().unwrap(), + 17, + "passthrough metadata reaches the destination as it was given" + ); +} diff --git a/packages/stack-encrypt/tests/ui.rs b/packages/stack-encrypt/tests/ui.rs new file mode 100644 index 000000000..2ce0f68fd --- /dev/null +++ b/packages/stack-encrypt/tests/ui.rs @@ -0,0 +1,19 @@ +//! Compile-fail tests for the derive diagnostics: every `tests/ui/*.rs` must +//! fail to compile with exactly the `.stderr` beside it. Regenerate the +//! expectations after a deliberate message change with `TRYBUILD=overwrite`. +//! +//! The expectations are recorded with every feature on, as `test:unit` and +//! CI run them (`--all-features`), and this test is only built with the +//! `dynamic` feature (`required-features` in `Cargo.toml`): rustc lists a +//! trait's other implementors in its help, so that feature's `FfiValue` +//! joins one list and pushes another entry off it, and one recording cannot +//! hold under both feature sets. + +#[test] +fn derive_diagnostics() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui/*.rs"); + // And the shapes that must compile, so a diagnostic never grows to + // cover a valid call. + t.pass("tests/ui/pass/*.rs"); +} diff --git a/packages/stack-encrypt/tests/ui/aead_context_with_term.rs b/packages/stack-encrypt/tests/ui/aead_context_with_term.rs new file mode 100644 index 000000000..6ae723624 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/aead_context_with_term.rs @@ -0,0 +1,15 @@ +//! An `AeadContext` is the context of a ciphertext-only record: nothing +//! converts it into the `CallerContext` a term's declaration wants, so a +//! record declaring it cannot hold a term. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::AeadContext; +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +#[stash(plaintext = u32, context_type = AeadContext)] +struct Indexed { + c: StackCipherText, + hm: EqualityTerm, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr b/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr new file mode 100644 index 000000000..1087c647c --- /dev/null +++ b/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr @@ -0,0 +1,19 @@ +error[E0277]: the trait bound `CallerContext: From<AeadContext>` is not satisfied + --> tests/ui/aead_context_with_term.rs:8:10 + | +8 | #[derive(EncryptFrom)] + | ^^^^^^^^^^^ the trait `From<AeadContext>` is not implemented for `CallerContext` + | + = help: the following other types implement trait `From<T>`: + `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<i128>` + `CallerContext` implements `From<i16>` + `CallerContext` implements `From<i32>` + `CallerContext` implements `From<i64>` + `CallerContext` implements `From<i8>` + `CallerContext` implements `From<u128>` + `CallerContext` implements `From<u16>` + and $N others + = note: required for `AeadContext` to implement `Into<CallerContext>` + = help: see issue #48214 + = note: this error originates in the derive macro `EncryptFrom` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/packages/stack-encrypt/tests/ui/bare_context.rs b/packages/stack-encrypt/tests/ui/bare_context.rs new file mode 100644 index 000000000..e93bdd9b2 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/bare_context.rs @@ -0,0 +1,56 @@ +//! The `_with_context` forms take anything that converts into a +//! `NonEmpty<_>` — a `nonempty!(..)` literal, a `NonEmpty::new(..)?` value, +//! a bare integer — and nothing unproven: a `&str` is not a context until it +//! has been checked, so it is turned away at the call, not at a leaf. That +//! holds on every path that reaches a leaf — a term, a struct record, a +//! plaintext-derived record, a leaf opened directly — so the proof cannot be +//! skipped by passing the raw value. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptFrom, EncryptInto}; +use stack_encrypt::{DecryptInto, EncryptFrom, KeysetCipher, StackCipher, StackCipherText}; +use stack_kms::FakeDataKeySource; + +struct User { + email: String, +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + email: StackCipherText, +} + +#[derive(EncryptFrom)] +#[stash(plaintext = u32)] +struct EncryptedAge { + c: StackCipherText, + hm: EqualityTerm, +} + +async fn encrypt(cipher: &KeysetCipher<'_, FakeDataKeySource>, user: User) { + let _term: EqualityTerm = "alice" + .encrypt_into_with_context(cipher, "users/email") + .await + .unwrap(); + let _row: EncryptedUser = user + .encrypt_into_with_context(cipher, "tenant/acme") + .await + .unwrap(); + let _record: EncryptedAge = 42u32 + .encrypt_into_with_context(cipher, "users/age") + .await + .unwrap(); +} + +async fn decrypt( + cipher: &StackCipher<FakeDataKeySource>, + row: EncryptedUser, + sealed: StackCipherText, +) { + let _user = User::decrypt_from_with_context(row, cipher, "tenant/acme") + .await + .unwrap(); + let _age: u32 = sealed.decrypt_into(cipher, "users/age").await.unwrap(); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/bare_context.stderr b/packages/stack-encrypt/tests/ui/bare_context.stderr new file mode 100644 index 000000000..ad2d31cb0 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/bare_context.stderr @@ -0,0 +1,139 @@ +error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied + --> tests/ui/bare_context.rs:32:44 + | +32 | .encrypt_into_with_context(cipher, "users/email") + | ------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `CallerContext` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `From<T>`: + `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<i128>` + `CallerContext` implements `From<i16>` + `CallerContext` implements `From<i32>` + `CallerContext` implements `From<i64>` + `CallerContext` implements `From<i8>` + `CallerContext` implements `From<u128>` + `CallerContext` implements `From<u16>` + and $N others + = note: required for `&str` to implement `Into<CallerContext>` +note: required by a bound in `encrypt_into_with_context` + --> src/target/operations.rs + | + | fn encrypt_into_with_context<'a, T, K: 'static>( + | ------------------------- required by a bound in this associated function +... + | context: impl Into<T::Context>, + | ^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` + +error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied + --> tests/ui/bare_context.rs:36:44 + | +36 | .encrypt_into_with_context(cipher, "tenant/acme") + | ------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `DeclaredContext` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `From<T>`: + `DeclaredContext` implements `From<()>` + `DeclaredContext` implements `From<CallerContext>` + `DeclaredContext` implements `From<NonEmpty<T>>` + `DeclaredContext` implements `From<i128>` + `DeclaredContext` implements `From<i16>` + `DeclaredContext` implements `From<i32>` + `DeclaredContext` implements `From<i64>` + `DeclaredContext` implements `From<i8>` + and $N others + = note: required for `&str` to implement `Into<DeclaredContext>` +note: required by a bound in `encrypt_into_with_context` + --> src/target/operations.rs + | + | fn encrypt_into_with_context<'a, T, K: 'static>( + | ------------------------- required by a bound in this associated function +... + | context: impl Into<T::Context>, + | ^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` + +error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied + --> tests/ui/bare_context.rs:40:44 + | +40 | .encrypt_into_with_context(cipher, "users/age") + | ------------------------- ^^^^^^^^^^^ the trait `From<&str>` is not implemented for `CallerContext` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `From<T>`: + `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<i128>` + `CallerContext` implements `From<i16>` + `CallerContext` implements `From<i32>` + `CallerContext` implements `From<i64>` + `CallerContext` implements `From<i8>` + `CallerContext` implements `From<u128>` + `CallerContext` implements `From<u16>` + and $N others + = note: required for `&str` to implement `Into<CallerContext>` +note: required by a bound in `encrypt_into_with_context` + --> src/target/operations.rs + | + | fn encrypt_into_with_context<'a, T, K: 'static>( + | ------------------------- required by a bound in this associated function +... + | context: impl Into<T::Context>, + | ^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` + +error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied + --> tests/ui/bare_context.rs:50:62 + | +50 | let _user = User::decrypt_from_with_context(row, cipher, "tenant/acme") + | ------------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `DeclaredContext` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `From<T>`: + `DeclaredContext` implements `From<()>` + `DeclaredContext` implements `From<CallerContext>` + `DeclaredContext` implements `From<NonEmpty<T>>` + `DeclaredContext` implements `From<i128>` + `DeclaredContext` implements `From<i16>` + `DeclaredContext` implements `From<i32>` + `DeclaredContext` implements `From<i64>` + `DeclaredContext` implements `From<i8>` + and $N others + = note: required for `&str` to implement `Into<DeclaredContext>` +note: required by a bound in `decrypt_from_with_context` + --> src/target/operations.rs + | + | fn decrypt_from_with_context<'a, S, K: 'static>( + | ------------------------- required by a bound in this associated function +... + | context: impl Into<S::Context>, + | ^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from_with_context` + +error[E0277]: the trait bound `AeadContext: From<&str>` is not satisfied + --> tests/ui/bare_context.rs:53:49 + | +53 | let _age: u32 = sealed.decrypt_into(cipher, "users/age").await.unwrap(); + | ------------ ^^^^^^^^^^^ the trait `From<&str>` is not implemented for `AeadContext` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `From<T>`: + `AeadContext` implements `From<CallerContext>` + `AeadContext` implements `From<NonEmpty<T>>` + `AeadContext` implements `From<i128>` + `AeadContext` implements `From<i16>` + `AeadContext` implements `From<i32>` + `AeadContext` implements `From<i64>` + `AeadContext` implements `From<i8>` + `AeadContext` implements `From<u128>` + and $N others + = note: required for `&str` to implement `Into<AeadContext>` +note: required by a bound in `decrypt_into` + --> src/target/operations.rs + | + | fn decrypt_into<'a, P: 'static, K: 'static>( + | ------------ required by a bound in this associated function +... + | context: impl Into<<Self as DecryptInto<P>>::Context>, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_into` diff --git a/packages/stack-encrypt/tests/ui/context_field_conflicts.rs b/packages/stack-encrypt/tests/ui/context_field_conflicts.rs new file mode 100644 index 000000000..f6d91dddd --- /dev/null +++ b/packages/stack-encrypt/tests/ui/context_field_conflicts.rs @@ -0,0 +1,23 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; +#[derive(EncryptFrom)] +struct Twice { + #[stash(context_field)] + one: String, + #[stash(context_field)] + two: String, + c: StackCipherText, +} +#[derive(EncryptFrom)] +struct Literal { + #[stash(context_field)] + i: String, + #[stash(context = "other")] + c: StackCipherText, +} +#[derive(EncryptFrom)] +struct Defaulted { + #[stash(context_field, default)] + i: String, + c: StackCipherText, +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr b/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr new file mode 100644 index 000000000..7a73b9665 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr @@ -0,0 +1,17 @@ +error: a record has exactly one `context_field` + --> tests/ui/context_field_conflicts.rs:3:8 + | +3 | struct Twice { + | ^^^^^ + +error: `context_field` supplies the complete context; literal field contexts do not apply + --> tests/ui/context_field_conflicts.rs:11:8 + | +11 | struct Literal { + | ^^^^^^^ + +error: `context_field` is metadata and cannot also be derived or defaulted + --> tests/ui/context_field_conflicts.rs:20:8 + | +20 | i: String, + | ^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/context_type_conflicts.rs b/packages/stack-encrypt/tests/ui/context_type_conflicts.rs new file mode 100644 index 000000000..b61498f6c --- /dev/null +++ b/packages/stack-encrypt/tests/ui/context_type_conflicts.rs @@ -0,0 +1,33 @@ +//! `context_type` names what the caller passes to a record whose fields take +//! the caller's context. The three shapes that settle the context themselves +//! refuse it, and like every other singular attribute it is given once. +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +#[stash(struct = User, context = "users", context_type = stack_encrypt::target::AeadContext)] +struct ByField { + name: StackCipherText, +} + +#[derive(EncryptFrom)] +#[stash(plaintext = String, context_type = stack_encrypt::target::AeadContext)] +struct Stored { + #[stash(context_field)] + tenant: String, + c: StackCipherText, +} + +#[derive(EncryptFrom)] +#[stash(plaintext = String, context_type = stack_encrypt::target::AeadContext)] +struct Declared { + #[stash(context = "users/name")] + c: StackCipherText, +} + +#[derive(EncryptFrom)] +#[stash(context_type = stack_encrypt::target::AeadContext, context_type = stack_encrypt::target::AeadContext)] +struct Twice { + c: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/context_type_conflicts.stderr b/packages/stack-encrypt/tests/ui/context_type_conflicts.stderr new file mode 100644 index 000000000..ba10b6220 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/context_type_conflicts.stderr @@ -0,0 +1,29 @@ +error: `context_type` names what the caller passes to a record whose fields take the caller's context; a `struct` derive's fields carry their own, so the record takes `DeclaredContext` and a caller's context extends them + --> tests/ui/context_type_conflicts.rs:7:58 + | +7 | #[stash(struct = User, context = "users", context_type = stack_encrypt::target::AeadContext)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: `struct` given here + --> tests/ui/context_type_conflicts.rs:7:18 + | +7 | #[stash(struct = User, context = "users", context_type = stack_encrypt::target::AeadContext)] + | ^^^^ + +error: `context_field` supplies the complete context, so the record's `Context` is `NonEmpty<T>` of that field's type; `context_type` does not apply + --> tests/ui/context_type_conflicts.rs:13:44 + | +13 | #[stash(plaintext = String, context_type = stack_encrypt::target::AeadContext)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: `context_type` names what the caller passes to a record whose fields take the caller's context; every field here carries a `context = ".."` of its own, so the record takes `DeclaredContext` and a caller's context extends them + --> tests/ui/context_type_conflicts.rs:21:44 + | +21 | #[stash(plaintext = String, context_type = stack_encrypt::target::AeadContext)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: `context_type` is given twice; a record has one associated context + --> tests/ui/context_type_conflicts.rs:28:60 + | +28 | #[stash(context_type = stack_encrypt::target::AeadContext, context_type = stack_encrypt::target::AeadContext)] + | ^^^^^^^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs new file mode 100644 index 000000000..65ad08e12 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs @@ -0,0 +1,16 @@ +use stack_encrypt::{DecryptInto, StackCipherText}; + +struct User { + a: u32, +} + +#[derive(DecryptInto)] +#[stash(struct = User, context = "users")] +struct Rec { + #[stash(decrypt)] + a: StackCipherText, + #[stash(decrypt, from = a)] + b: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.stderr b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.stderr new file mode 100644 index 000000000..982881b62 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.stderr @@ -0,0 +1,5 @@ +error: two `decrypt` fields would recover the same plaintext field `a` + --> tests/ui/decrypt_duplicate_from.rs:12:29 + | +12 | #[stash(decrypt, from = a)] + | ^ diff --git a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.rs b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.rs new file mode 100644 index 000000000..676b0e140 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.rs @@ -0,0 +1,13 @@ +use stack_encrypt::{DecryptInto, StackCipherText}; + +/// A hand-written leaf that says nothing about whether it is decryptable. +struct Opaque; + +#[derive(DecryptInto)] +#[stash(plaintext = u32)] +struct Rec { + c: StackCipherText, + o: Opaque, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr new file mode 100644 index 000000000..e2e9fda35 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr @@ -0,0 +1,42 @@ +error[E0277]: the trait bound `Opaque: DecryptField<u32, CallerContext>` is not satisfied + --> tests/ui/decrypt_field_not_decryptable.rs:6:10 + | +6 | #[derive(DecryptInto)] + | ^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `DecryptField<u32, CallerContext>` is not implemented for `Opaque` + --> tests/ui/decrypt_field_not_decryptable.rs:4:1 + | +4 | struct Opaque; + | ^^^^^^^^^^^^^ + = help: the following other types implement trait `DecryptField<P, Ctx>`: + `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` implements `DecryptField<P, Ctx>` + `EqualityTerm` implements `DecryptField<P, Ctx>` + `MatchTerm<O>` implements `DecryptField<P, Ctx>` + `OpeTerm<T>` implements `DecryptField<P, Ctx>` + `Option<T>` implements `DecryptField<Option<P>, Ctx>` + `OreTerm<T>` implements `DecryptField<P, Ctx>` + `Rec` implements `DecryptField<__P, __Ctx>` + `Vec<T>` implements `DecryptField<Vec<P>, Ctx>` + = help: see issue #48214 + = note: this error originates in the derive macro `DecryptInto` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `Opaque: Decryptable` is not satisfied + --> tests/ui/decrypt_field_not_decryptable.rs:10:8 + | +10 | o: Opaque, + | ^^^^^^ unsatisfied trait bound + | +help: the trait `Decryptable` is not implemented for `Opaque` + --> tests/ui/decrypt_field_not_decryptable.rs:4:1 + | + 4 | struct Opaque; + | ^^^^^^^^^^^^^ + = help: the following other types implement trait `Decryptable`: + CipherText<SealedValue, Box<(dyn Any + Send + 'static)>> + EqualityTerm + MatchTerm<O> + OpeTerm<T> + Option<T> + OreTerm<T> + Vec<T> diff --git a/packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.rs b/packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.rs new file mode 100644 index 000000000..86be6d106 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.rs @@ -0,0 +1,11 @@ +use stack_encrypt::sem::{EqualityTerm, OreTerm}; +use stack_encrypt::DecryptInto; + +#[derive(DecryptInto)] +#[stash(plaintext = u32)] +struct Rec { + hm: EqualityTerm, + ob: OreTerm<u32>, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.stderr b/packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.stderr new file mode 100644 index 000000000..51acb5612 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.stderr @@ -0,0 +1,5 @@ +error[E0080]: evaluation panicked: `Rec` has no decryptable field: every derived field is a one-way index term, so there is nothing for DecryptInto to open + --> tests/ui/decrypt_no_decryptable_field.rs:4:10 + | +4 | #[derive(DecryptInto)] + | ^^^^^^^^^^^ evaluation of `_` failed here diff --git a/packages/stack-encrypt/tests/ui/decrypt_several_decryptable_fields.rs b/packages/stack-encrypt/tests/ui/decrypt_several_decryptable_fields.rs new file mode 100644 index 000000000..09419eb56 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_several_decryptable_fields.rs @@ -0,0 +1,10 @@ +use stack_encrypt::{DecryptInto, StackCipherText}; + +#[derive(DecryptInto)] +#[stash(plaintext = u32)] +struct Rec { + a: StackCipherText, + b: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_several_decryptable_fields.stderr b/packages/stack-encrypt/tests/ui/decrypt_several_decryptable_fields.stderr new file mode 100644 index 000000000..4cd2aa240 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_several_decryptable_fields.stderr @@ -0,0 +1,5 @@ +error[E0080]: evaluation panicked: `Rec` has several decryptable fields: mark the one decryption opens `#[stash(decrypt)]` + --> tests/ui/decrypt_several_decryptable_fields.rs:3:10 + | +3 | #[derive(DecryptInto)] + | ^^^^^^^^^^^ evaluation of `_` failed here diff --git a/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.rs b/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.rs new file mode 100644 index 000000000..4239673be --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.rs @@ -0,0 +1,15 @@ +use stack_encrypt::{DecryptInto, StackCipherText}; + +struct User { + email: String, +} + +#[derive(DecryptInto)] +#[stash(struct = User, context = "users")] +struct Rec { + email: StackCipherText, + #[stash(from = email, context = "users/email/copy")] + email_copy: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.stderr b/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.stderr new file mode 100644 index 000000000..d825394f7 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.stderr @@ -0,0 +1,5 @@ +error[E0080]: evaluation panicked: several fields of `Rec` are derived from the plaintext field `email` and decryptable: mark the one decryption opens `#[stash(decrypt)]` + --> tests/ui/decrypt_several_recover_one_field.rs:7:10 + | +7 | #[derive(DecryptInto)] + | ^^^^^^^^^^^ evaluation of `_` failed here diff --git a/packages/stack-encrypt/tests/ui/default_with_context.rs b/packages/stack-encrypt/tests/ui/default_with_context.rs new file mode 100644 index 000000000..6b8bc43bf --- /dev/null +++ b/packages/stack-encrypt/tests/ui/default_with_context.rs @@ -0,0 +1,10 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +struct Rec { + c: StackCipherText, + #[stash(default, context = "x")] + v: u8, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/default_with_context.stderr b/packages/stack-encrypt/tests/ui/default_with_context.stderr new file mode 100644 index 000000000..efd4e81c3 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/default_with_context.stderr @@ -0,0 +1,5 @@ +error: a `default` field is not derived from the source, so `context`, `from`, `decrypt` and `nested` do not apply to it + --> tests/ui/default_with_context.rs:7:8 + | +7 | v: u8, + | ^^ diff --git a/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs b/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs new file mode 100644 index 000000000..8915bb9ff --- /dev/null +++ b/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs @@ -0,0 +1,35 @@ +//! Within one target there is no second context to pass: `zip` hands both +//! sides the one context the tree carries, and requires both to need the +//! same type of it. A subtree given a context of its own with `under` may +//! run under `()`, so it needs a `DeclaredContext`; a bare operation needs a +//! real `CallerContext`; the two do not zip. So a target cannot make the +//! caller's context optional for its ciphertext while its term still needs +//! one — the empty-context rule, reaching through `zip`. +//! +//! That is the divergence the type system refuses. The one it cannot refuse +//! is two `under`s in one target, each naming its own context: that compiles, +//! being the same construct as a record naming the contexts of two fields +//! (ADR-0004, decision 1). +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{ciphertext, equality, DeclaredContext, EncryptFrom, Encryption}; +use stack_encrypt::{nonempty, StackCipherText}; + +struct Divergent { + c: StackCipherText, + hm: EqualityTerm, +} + +impl EncryptFrom<String> for Divergent { + type Context = DeclaredContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + where + String: 's, + { + ciphertext() + .under(nonempty!("users/email")) + .zip(equality()) + .map(|(c, hm)| Self { c, hm }) + } +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr b/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr new file mode 100644 index 000000000..fc60d6a2d --- /dev/null +++ b/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr @@ -0,0 +1,15 @@ +error[E0308]: mismatched types + --> tests/ui/divergent_context_in_target.rs:30:18 + | +30 | .zip(equality()) + | --- ^^^^^^^^^^ expected `Encryption<'_, _, _, _, DeclaredContext>`, found `Encryption<'_, _, EqualityTerm, _, ...>` + | | + | arguments to this method are incorrect + | + = note: expected struct `Encryption<'_, _, _, _, DeclaredContext>` + found struct `Encryption<'_, _, EqualityTerm, _, CallerContext>` +note: method defined here + --> src/target/operations.rs + | + | pub fn zip<U: 'static>( + | ^^^ diff --git a/packages/stack-encrypt/tests/ui/drop_record.rs b/packages/stack-encrypt/tests/ui/drop_record.rs new file mode 100644 index 000000000..023a46a10 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/drop_record.rs @@ -0,0 +1,19 @@ +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::{DecryptInto, EncryptFrom, StackCipherText}; + +// `DecryptInto` moves the opened field out of `self`, which a `Drop` type +// (including `ZeroizeOnDrop`) forbids. The restriction is documented; this +// pins what the user sees. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct Rec { + #[stash(decrypt)] + c: StackCipherText, + hm: EqualityTerm, +} + +impl Drop for Rec { + fn drop(&mut self) {} +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/drop_record.stderr b/packages/stack-encrypt/tests/ui/drop_record.stderr new file mode 100644 index 000000000..acf842fcd --- /dev/null +++ b/packages/stack-encrypt/tests/ui/drop_record.stderr @@ -0,0 +1,8 @@ +error[E0509]: cannot move out of type `Rec`, which implements the `Drop` trait + --> tests/ui/drop_record.rs:11:5 + | +11 | c: StackCipherText, + | ^^^^^^^^^^^^^^^^^^ + | | + | cannot move out of here + | move occurs because value has type `CipherText<SealedValue, Box<dyn Any + Send>>`, which does not implement the `Copy` trait diff --git a/packages/stack-encrypt/tests/ui/duplicate_plaintext.rs b/packages/stack-encrypt/tests/ui/duplicate_plaintext.rs new file mode 100644 index 000000000..a6a0cce80 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/duplicate_plaintext.rs @@ -0,0 +1,9 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +#[stash(plaintext = u32, plaintext = u32)] +struct Dup { + c: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/duplicate_plaintext.stderr b/packages/stack-encrypt/tests/ui/duplicate_plaintext.stderr new file mode 100644 index 000000000..d1bac0874 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/duplicate_plaintext.stderr @@ -0,0 +1,5 @@ +error: this `plaintext` is listed twice; each listed type gets one impl + --> tests/ui/duplicate_plaintext.rs:4:38 + | +4 | #[stash(plaintext = u32, plaintext = u32)] + | ^^^ diff --git a/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs b/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs new file mode 100644 index 000000000..5c734f590 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs @@ -0,0 +1,40 @@ +use stack_encrypt::{DecryptInto, EncryptFrom, StackCipherText}; + +struct User { + expected: u32, + other: u32, +} + +#[derive(EncryptFrom)] +#[stash(struct = User, context = "users")] +struct DupFrom { + #[stash(from = expected, from = other)] + c: StackCipherText, +} + +#[derive(EncryptFrom)] +struct DupContext { + #[stash(context = "users/email", context = "users/name")] + c: StackCipherText, +} + +#[derive(EncryptFrom)] +struct DupDefault { + c: StackCipherText, + #[stash(default, default = 3)] + v: u8, +} + +#[derive(DecryptInto)] +struct DupDecrypt { + #[stash(decrypt, decrypt)] + c: StackCipherText, +} + +#[derive(EncryptFrom)] +#[stash(crate = "stack_encrypt", crate = "stack_encrypt")] +struct DupCrate { + c: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.stderr b/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.stderr new file mode 100644 index 000000000..a8f48d66e --- /dev/null +++ b/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.stderr @@ -0,0 +1,29 @@ +error: `from` is given twice; a field is derived from one plaintext field + --> tests/ui/duplicate_singleton_attrs.rs:11:30 + | +11 | #[stash(from = expected, from = other)] + | ^^^^ + +error: `context` is given twice; a field has one context + --> tests/ui/duplicate_singleton_attrs.rs:17:38 + | +17 | #[stash(context = "users/email", context = "users/name")] + | ^^^^^^^ + +error: `default` is given twice + --> tests/ui/duplicate_singleton_attrs.rs:24:22 + | +24 | #[stash(default, default = 3)] + | ^^^^^^^ + +error: `decrypt` is given twice + --> tests/ui/duplicate_singleton_attrs.rs:30:22 + | +30 | #[stash(decrypt, decrypt)] + | ^^^^^^^ + +error: `crate` is given twice + --> tests/ui/duplicate_singleton_attrs.rs:35:34 + | +35 | #[stash(crate = "stack_encrypt", crate = "stack_encrypt")] + | ^^^^^ diff --git a/packages/stack-encrypt/tests/ui/empty_context.rs b/packages/stack-encrypt/tests/ui/empty_context.rs new file mode 100644 index 000000000..7394ee21e --- /dev/null +++ b/packages/stack-encrypt/tests/ui/empty_context.rs @@ -0,0 +1,21 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; + +struct User { + email: String, +} + +#[derive(EncryptFrom)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + #[stash(context = "")] + email: StackCipherText, +} + +#[derive(EncryptFrom)] +#[stash(plaintext = u32)] +struct Pinned { + #[stash(context = "")] + c: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/empty_context.stderr b/packages/stack-encrypt/tests/ui/empty_context.stderr new file mode 100644 index 000000000..31dbaeb0d --- /dev/null +++ b/packages/stack-encrypt/tests/ui/empty_context.stderr @@ -0,0 +1,11 @@ +error: an empty `context` is rejected when a value is encrypted: name the field (e.g. "users/email"), or drop the attribute to use the inferred `"<context>/<field>"` + --> tests/ui/empty_context.rs:10:23 + | +10 | #[stash(context = "")] + | ^^ + +error: an empty `context` is rejected when a value is encrypted: name the field (e.g. "users/email"), or drop the attribute to hand the field the caller's context + --> tests/ui/empty_context.rs:17:23 + | +17 | #[stash(context = "")] + | ^^ diff --git a/packages/stack-encrypt/tests/ui/enum_record.rs b/packages/stack-encrypt/tests/ui/enum_record.rs new file mode 100644 index 000000000..8f9f02896 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/enum_record.rs @@ -0,0 +1,9 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +enum Choice { + A(StackCipherText), + B(StackCipherText), +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/enum_record.stderr b/packages/stack-encrypt/tests/ui/enum_record.stderr new file mode 100644 index 000000000..63eb02cad --- /dev/null +++ b/packages/stack-encrypt/tests/ui/enum_record.stderr @@ -0,0 +1,5 @@ +error: EncryptFrom/DecryptInto cannot be derived for enums: a record is a fixed set of fields derived from one source, and a variant choice has no field to be derived into. Model the choice explicitly instead, e.g. as a struct of `Option` fields. + --> tests/ui/enum_record.rs:4:6 + | +4 | enum Choice { + | ^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/execution_callback.rs b/packages/stack-encrypt/tests/ui/execution_callback.rs new file mode 100644 index 000000000..cd4fa3c0b --- /dev/null +++ b/packages/stack-encrypt/tests/ui/execution_callback.rs @@ -0,0 +1,10 @@ +use stack_encrypt::target::CallerContext; +use stack_encrypt::{Encryption, StackCipherText}; +fn main() { + // Target authors cannot install a callback that receives plaintext + cipher. + let _: Encryption<'_, u32, StackCipherText, (), CallerContext> = Encryption { + build: Box::new(|_, cipher, _| { + stack_encrypt::Pending::failed(cipher, stack_encrypt::Error::Aead) + }), + }; +} diff --git a/packages/stack-encrypt/tests/ui/execution_callback.stderr b/packages/stack-encrypt/tests/ui/execution_callback.stderr new file mode 100644 index 000000000..2369603a3 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/execution_callback.stderr @@ -0,0 +1,7 @@ +error[E0451]: field `build` of struct `Encryption` is private + --> tests/ui/execution_callback.rs:6:9 + | +5 | let _: Encryption<'_, u32, StackCipherText, (), CallerContext> = Encryption { + | ---------- in this type +6 | build: Box::new(|_, cipher, _| { + | ^^^^^ private field diff --git a/packages/stack-encrypt/tests/ui/foreign_cipher_scope.rs b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.rs new file mode 100644 index 000000000..4d5ac50f1 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.rs @@ -0,0 +1,21 @@ +//! `CipherScope` is sealed: a scope names a keyset the cipher loaded from +//! ZeroKMS, so an outside crate cannot invent one that claims an id it does +//! not hold. + +use stack_encrypt::target::CipherScope; +use stack_encrypt::StackCipher; +use uuid::Uuid; + +struct AnyKeyset<'a, K>(&'a StackCipher<K>, Uuid); + +impl<'a, K> CipherScope<'a, K> for AnyKeyset<'a, K> { + fn cipher(&self) -> &'a StackCipher<K> { + self.0 + } + + fn keyset(&self) -> Option<Uuid> { + Some(self.1) + } +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr new file mode 100644 index 000000000..f0646d8f2 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr @@ -0,0 +1,28 @@ +error[E0277]: the trait bound `AnyKeyset<'a, K>: target::pending::sealed::Sealed` is not satisfied + --> tests/ui/foreign_cipher_scope.rs:11:36 + | +11 | impl<'a, K> CipherScope<'a, K> for AnyKeyset<'a, K> { + | ^^^^^^^^^^^^^^^^ unsatisfied trait bound + | +help: the trait `target::pending::sealed::Sealed` is not implemented for `AnyKeyset<'a, K>` + --> tests/ui/foreign_cipher_scope.rs:9:1 + | + 9 | struct AnyKeyset<'a, K>(&'a StackCipher<K>, Uuid); + | ^^^^^^^^^^^^^^^^^^^^^^^ + = note: `AnyKeyset<'a, K>` implements similarly named trait `unicode_width::private::Sealed`, but not `target::pending::sealed::Sealed` +help: the following other types implement trait `target::pending::sealed::Sealed` + --> src/target/pending.rs + | + | impl<K> Sealed for &crate::StackCipher<K> {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `&StackCipher<K>` + | impl<K> Sealed for &crate::KeysetCipher<'_, K> {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `&KeysetCipher<'_, K>` +note: required by a bound in `CipherScope` + --> src/target/pending.rs + | + | pub trait CipherScope<'a, K>: sealed::Sealed { + | ^^^^^^^^^^^^^^ required by this bound in `CipherScope` + = note: `CipherScope` is a "sealed trait", because to implement it you also need to implement `stack_encrypt::target::pending::sealed::Sealed`, which is not accessible; this is usually done to force you to use one of the provided types that already implement it + = help: the following types implement the trait: + &stack_encrypt::StackCipher<K> + &stack_encrypt::KeysetCipher<'_, K> diff --git a/packages/stack-encrypt/tests/ui/from_without_plaintext.rs b/packages/stack-encrypt/tests/ui/from_without_plaintext.rs new file mode 100644 index 000000000..86021e421 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/from_without_plaintext.rs @@ -0,0 +1,23 @@ +//! `from = ..` reaches into a field of the plaintext, which is what +//! `#[stash(struct = ..)]` means; a `plaintext` record derives every field +//! from the whole value. +use stack_encrypt::{EncryptFrom, StackCipherText}; + +struct User { + age: u32, +} + +#[derive(EncryptFrom)] +#[stash(plaintext = User)] +struct Row { + #[stash(from = age, context = "users/age")] + age: StackCipherText, +} + +#[derive(EncryptFrom)] +struct Unnamed { + #[stash(from = age, context = "users/age")] + age: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr b/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr new file mode 100644 index 000000000..e09c08e68 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr @@ -0,0 +1,11 @@ +error: `from = ..` reaches into a field of the plaintext, which is what `#[stash(struct = ..)]` does: a `plaintext` record derives every field from the whole value + --> tests/ui/from_without_plaintext.rs:13:20 + | +13 | #[stash(from = age, context = "users/age")] + | ^^^ + +error: `from = ..` reaches into a field of the plaintext, which is what `#[stash(struct = ..)]` does: a `plaintext` record derives every field from the whole value + --> tests/ui/from_without_plaintext.rs:19:20 + | +19 | #[stash(from = age, context = "users/age")] + | ^^^ diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.rs b/packages/stack-encrypt/tests/ui/leaf_without_context.rs new file mode 100644 index 000000000..4e867f61d --- /dev/null +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.rs @@ -0,0 +1,30 @@ +//! A leaf, a record that hands the caller's context to one, and a column of +//! either all need a `NonEmpty<_>` context: the context-free `encrypt_into` +//! / `decrypt_from` pass `()`, which no leaf accepts. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptFrom, EncryptInto}; +use stack_encrypt::{DecryptInto, EncryptFrom, KeysetCipher, StackCipher, StackCipherText}; +use stack_kms::FakeDataKeySource; + +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct EncryptedAge { + c: StackCipherText, + hm: EqualityTerm, +} + +async fn encrypt(cipher: &KeysetCipher<'_, FakeDataKeySource>) { + let _term: EqualityTerm = "alice".encrypt_into(cipher).await.unwrap(); + let _record: EncryptedAge = 42u32.encrypt_into(cipher).await.unwrap(); + let _column: Vec<StackCipherText> = vec![1u32].encrypt_into(cipher).await.unwrap(); +} + +async fn decrypt(cipher: &StackCipher<FakeDataKeySource>, record: EncryptedAge) { + let _age = u32::decrypt_from(record, cipher).await.unwrap(); +} + +async fn decrypt_leaf(cipher: &StackCipher<FakeDataKeySource>, record: EncryptedAge) { + let _age: u32 = record.decrypt_into(cipher, ()).await.unwrap(); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr new file mode 100644 index 000000000..7e167bc2a --- /dev/null +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr @@ -0,0 +1,91 @@ +error[E0277]: the trait bound `CallerContext: Default` is not satisfied + --> tests/ui/leaf_without_context.rs:17:39 + | +17 | let _term: EqualityTerm = "alice".encrypt_into(cipher).await.unwrap(); + | ^^^^^^^^^^^^ the trait `Default` is not implemented for `CallerContext` + | +note: required by a bound in `encrypt_into` + --> src/target/operations.rs + | + | fn encrypt_into<'a, T, K: 'static>(&self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> + | ------------ required by a bound in this associated function +... + | T::Context: Default, + | ^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + +error[E0277]: the trait bound `CallerContext: Default` is not satisfied + --> tests/ui/leaf_without_context.rs:18:39 + | +18 | let _record: EncryptedAge = 42u32.encrypt_into(cipher).await.unwrap(); + | ^^^^^^^^^^^^ the trait `Default` is not implemented for `CallerContext` + | +note: required by a bound in `encrypt_into` + --> src/target/operations.rs + | + | fn encrypt_into<'a, T, K: 'static>(&self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> + | ------------ required by a bound in this associated function +... + | T::Context: Default, + | ^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + +error[E0277]: the trait bound `AeadContext: Default` is not satisfied + --> tests/ui/leaf_without_context.rs:19:41 + | +19 | let _column: Vec<StackCipherText> = vec![1u32].encrypt_into(cipher).await.unwrap(); + | ^^^^^^^^^^ ------------ required by a bound introduced by this call + | | + | the trait `Default` is not implemented for `AeadContext` + | +note: required by a bound in `encrypt_into` + --> src/target/operations.rs + | + | fn encrypt_into<'a, T, K: 'static>(&self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> + | ------------ required by a bound in this associated function +... + | T::Context: Default, + | ^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + +error[E0277]: the trait bound `CallerContext: Default` is not satisfied + --> tests/ui/leaf_without_context.rs:23:34 + | +23 | let _age = u32::decrypt_from(record, cipher).await.unwrap(); + | ----------------- ^^^^^^ the trait `Default` is not implemented for `CallerContext` + | | + | required by a bound introduced by this call + | +note: required by a bound in `decrypt_from` + --> src/target/operations.rs + | + | fn decrypt_from<'a, S, K: 'static>( + | ------------ required by a bound in this associated function +... + | S::Context: Default, + | ^^^^^^^ required by this bound in `DecryptFrom::decrypt_from` + +error[E0277]: the trait bound `CallerContext: From<()>` is not satisfied + --> tests/ui/leaf_without_context.rs:27:49 + | +27 | let _age: u32 = record.decrypt_into(cipher, ()).await.unwrap(); + | ------------ ^^ the trait `From<()>` is not implemented for `CallerContext` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `From<T>`: + `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<i128>` + `CallerContext` implements `From<i16>` + `CallerContext` implements `From<i32>` + `CallerContext` implements `From<i64>` + `CallerContext` implements `From<i8>` + `CallerContext` implements `From<u128>` + `CallerContext` implements `From<u16>` + and $N others + = note: required for `()` to implement `Into<CallerContext>` +note: required by a bound in `decrypt_into` + --> src/target/operations.rs + | + | fn decrypt_into<'a, P: 'static, K: 'static>( + | ------------ required by a bound in this associated function +... + | context: impl Into<<Self as DecryptInto<P>>::Context>, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_into` diff --git a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.rs b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.rs new file mode 100644 index 000000000..4e6c65f2f --- /dev/null +++ b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.rs @@ -0,0 +1,18 @@ +//! A `nested` field is handed the caller's context as it is — `()` in the +//! record's `()` impl. A leaf accepts only a `NonEmpty<_>`, and says so at +//! the field: `nested` is for a field whose type carries its own contexts; +//! a leaf takes the inferred one, or a `context = ".."`. +use stack_encrypt::{DecryptInto, EncryptFrom, StackCipherText}; + +struct User { + email: String, +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + #[stash(nested)] + email: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr new file mode 100644 index 000000000..e6f1c201d --- /dev/null +++ b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr @@ -0,0 +1,44 @@ +error[E0277]: the trait bound `AeadContext: From<DeclaredContext>` is not satisfied + --> tests/ui/nested_leaf_without_context.rs:15:12 + | +15 | email: StackCipherText, + | ^^^^^^^^^^^^^^^ the trait `From<DeclaredContext>` is not implemented for `AeadContext` + | + = help: the following other types implement trait `From<T>`: + `AeadContext` implements `From<CallerContext>` + `AeadContext` implements `From<NonEmpty<T>>` + `AeadContext` implements `From<i128>` + `AeadContext` implements `From<i16>` + `AeadContext` implements `From<i32>` + `AeadContext` implements `From<i64>` + `AeadContext` implements `From<i8>` + `AeadContext` implements `From<u128>` + and $N others + = note: required for `DeclaredContext` to implement `Into<AeadContext>` +note: required by a bound in `Encryption::<'s, S, T, K, Ctx>::accepting` + --> src/target/operations.rs + | + | pub fn accepting<C2>(self) -> Encryption<'s, S, T, K, C2> + | --------- required by a bound in this associated function + | where + | C2: Into<Ctx> + 's, + | ^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx>::accepting` + +error[E0277]: the trait bound `AeadContext: From<DeclaredContext>` is not satisfied + --> tests/ui/nested_leaf_without_context.rs:15:12 + | +15 | email: StackCipherText, + | ^^^^^^^^^^^^^^^ the trait `From<DeclaredContext>` is not implemented for `AeadContext` + | + = help: the following other types implement trait `From<T>`: + `AeadContext` implements `From<CallerContext>` + `AeadContext` implements `From<NonEmpty<T>>` + `AeadContext` implements `From<i128>` + `AeadContext` implements `From<i16>` + `AeadContext` implements `From<i32>` + `AeadContext` implements `From<i64>` + `AeadContext` implements `From<i8>` + `AeadContext` implements `From<u128>` + and $N others + = note: required for `DeclaredContext` to implement `Into<AeadContext>` + = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `DecryptField<_, DeclaredContext>` diff --git a/packages/stack-encrypt/tests/ui/nested_outside_struct.rs b/packages/stack-encrypt/tests/ui/nested_outside_struct.rs new file mode 100644 index 000000000..e08bae002 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/nested_outside_struct.rs @@ -0,0 +1,14 @@ +//! `nested` opts a field out of the context a `struct` derive infers. With a +//! `plaintext` record there is no inferred context to opt out of — a field +//! with no `context` is already handed the caller's — so it is rejected +//! rather than ignored. +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +#[stash(plaintext = u32)] +struct Rec { + #[stash(nested)] + c: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/nested_outside_struct.stderr b/packages/stack-encrypt/tests/ui/nested_outside_struct.stderr new file mode 100644 index 000000000..79e8452b7 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/nested_outside_struct.stderr @@ -0,0 +1,5 @@ +error: `nested` opts a field out of the context a `struct` derive infers, so it applies only with `struct = ..`; a `plaintext` record's field with no `context` is already handed the caller's + --> tests/ui/nested_outside_struct.rs:11:8 + | +11 | c: StackCipherText, + | ^^^^^^^^^^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/override_encryption.rs b/packages/stack-encrypt/tests/ui/override_encryption.rs new file mode 100644 index 000000000..a3710319e --- /dev/null +++ b/packages/stack-encrypt/tests/ui/override_encryption.rs @@ -0,0 +1,10 @@ +use stack_encrypt::{EncryptFrom, KeysetCipher, Pending}; +struct Target; +impl EncryptFrom<u32> for Target { + type Context = (); + // The old extension is deliberately not part of the declaration trait. + fn encrypt_from<'a,K>(_: &u32, cipher: &'a KeysetCipher<'_,K>, _:())->Pending<'a,Self,K> { + Pending::failed(cipher, stack_encrypt::Error::Aead) + } +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/override_encryption.stderr b/packages/stack-encrypt/tests/ui/override_encryption.stderr new file mode 100644 index 000000000..e6e84667a --- /dev/null +++ b/packages/stack-encrypt/tests/ui/override_encryption.stderr @@ -0,0 +1,18 @@ +error[E0407]: method `encrypt_from` is not a member of trait `EncryptFrom` + --> tests/ui/override_encryption.rs:6:5 + | +6 | fn encrypt_from<'a,K>(_: &u32, cipher: &'a KeysetCipher<'_,K>, _:())->Pending<'a,Self,K> { + | ^ ------------ help: there is an associated function with a similar name: `encryption` + | _____| + | | +7 | | Pending::failed(cipher, stack_encrypt::Error::Aead) +8 | | } + | |_____^ not a member of trait `EncryptFrom` + +error[E0046]: not all trait items implemented, missing: `encryption` + --> tests/ui/override_encryption.rs:3:1 + | +3 | impl EncryptFrom<u32> for Target { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `encryption` in implementation + | + = help: implement the missing item: `fn encryption<'s, K>() -> Encryption<'s, u32, Self, K, <Self as EncryptFrom<u32>>::Context> { todo!() }` diff --git a/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs b/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs new file mode 100644 index 000000000..bd8728ddd --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs @@ -0,0 +1,65 @@ +//! A context type that implements `IntoContext` is enough to seal a +//! ciphertext, so it must be enough for a derived record made only of +//! ciphertexts: `#[stash(context_type = AeadContext)]` declares that, and the +//! record then accepts exactly what the canonical `StackCipherText` path +//! accepts. `tests/ui/aead_context_with_term.rs` pins the record such a +//! context cannot declare. +use stack_encrypt::target::{AeadContext, DecryptFrom, EncryptInto}; +use stack_encrypt::{ContextPiece, DecryptInto, EncryptFrom, IntoContext, KeysetCipher, MaybeEmpty, NonEmpty, StackCipherText}; +use stack_kms::FakeDataKeySource; + +/// Declared through `AeadContext` below: it can seal, but the record +/// derives no term under it. +#[derive(Clone, Debug, PartialEq)] +struct Tenant(String); +impl MaybeEmpty for Tenant { + fn is_empty(&self) -> bool { + self.0.is_empty() + } +} +impl<'a> IntoContext<'a> for Tenant { + fn into_context(self) -> ContextPiece<'a> { + self.0.into_context() + } +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = String, context_type = AeadContext)] +struct Sealed { + c: StackCipherText, +} + +/// A field with a context of its own beside one that takes the caller's: +/// the literal is extended by the AEAD-only context, as it would be by a +/// `CallerContext`. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = String, context_type = AeadContext)] +struct Shadowed { + #[stash(decrypt)] + c: StackCipherText, + #[stash(context = "legacy/name")] + shadow: StackCipherText, +} + +fn tenant() -> NonEmpty<Tenant> { + NonEmpty::new(Tenant("acme".into())).unwrap() +} + +async fn canonical(cipher: &KeysetCipher<'_, FakeDataKeySource>, value: &String) { + let leaf: StackCipherText = value.encrypt_into_with_context(cipher, tenant()).await.unwrap(); + let _: String = leaf.decrypt_into(cipher, tenant()).await.unwrap(); +} + +async fn derived(cipher: &KeysetCipher<'_, FakeDataKeySource>, value: &String) { + let record: Sealed = value.encrypt_into_with_context(cipher, tenant()).await.unwrap(); + let _: String = record.decrypt_into(cipher, tenant()).await.unwrap(); + let record: Shadowed = value.encrypt_into_with_context(cipher, tenant()).await.unwrap(); + let _ = String::decrypt_from_with_context(record, cipher, tenant()).await.unwrap(); + let column: Vec<Sealed> = vec![value.clone()].encrypt_into_with_context(cipher, tenant()).await.unwrap(); + let _: Vec<String> = column.decrypt_into(cipher, tenant()).await.unwrap(); +} + +fn main() { + let _ = canonical; + let _ = derived; +} diff --git a/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs b/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs new file mode 100644 index 000000000..3ff7a83e1 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs @@ -0,0 +1,23 @@ +use stack_encrypt::{EncryptFrom, StackCipher, StackCipherText, nonempty}; +use stack_encrypt::sem::EqualityTerm; +use stack_kms::FakeDataKeySource; + +#[derive(EncryptFrom)] +struct Probe { hm: EqualityTerm } + +// No Encrypt bound: producing a term requires only the PRF capability. +fn query<S: vitaminc_prf::PrfValue + Clone>(cipher:&StackCipher<FakeDataKeySource>, value:&S) { + let keyset=cipher.default_keyset(); + let _ = keyset.encrypt_as::<_,Probe>(value,nonempty!("column").into()); +} +#[derive(EncryptFrom)] +struct Stored { c:StackCipherText } +async fn borrowed(cipher:&StackCipher<FakeDataKeySource>) { + let keyset=cipher.default_keyset(); + let pending = { + let text=String::from("borrowed"); + keyset.encrypt_as::<_,Stored>(&text.as_str(),nonempty!("column").into()) + }; + let _ = pending.await.unwrap(); +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs b/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs new file mode 100644 index 000000000..099fe8197 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs @@ -0,0 +1,87 @@ +//! The three record shapes and the call forms each accepts. Every line here +//! must compile; `tests/ui/leaf_without_context.rs` and +//! `tests/ui/bare_context.rs` pin the lines that must not. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptFrom, EncryptInto}; +use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, KeysetCipher, NonEmpty, StackCipherText}; +use stack_kms::FakeDataKeySource; + +/// Encrypting binds to a keyset; decrypting works through the same handle +/// (constrained to that keyset) as well as through the `StackCipher`. +type Cipher<'k> = KeysetCipher<'k, FakeDataKeySource>; + +/// A record whose one field pins a literal context: needs nothing from the +/// caller, and takes a context that then *extends* the literal. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct Pinned { + #[stash(context = "legacy/age")] + c: StackCipherText, +} + +async fn pinned(cipher: &Cipher<'_>, tenant_id: u64) -> Result<(), stack_encrypt::Error> { + // Sealed under "legacy/age". + let p: Pinned = 42u32.encrypt_into(cipher).await?; + let _: u32 = p.decrypt_into(cipher, ()).await?; + // Sealed under ("legacy/age", tenant_id). + let p: Pinned = 42u32.encrypt_into_with_context(cipher, tenant_id).await?; + let _: u32 = p.decrypt_into(cipher, NonEmpty::from(tenant_id)).await?; + Ok(()) +} + +/// A record whose fields have no context of their own: the caller's is +/// the only one there is, so it must be given. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct Foo { + c: StackCipherText, + hm: EqualityTerm, +} + +async fn foo(cipher: &Cipher<'_>, column: String) -> Result<(), stack_encrypt::Error> { + // A literal, a runtime value, a bare integer. + let f: Foo = 42u32.encrypt_into_with_context(cipher, nonempty!("users/age")).await?; + let _: u32 = f.decrypt_into(cipher, nonempty!("users/age")).await?; + let f: Foo = 42u32 + .encrypt_into_with_context(cipher, NonEmpty::new(column.clone()).expect("non-empty")) + .await?; + let _: u32 = f + .decrypt_into(cipher, NonEmpty::new(column).expect("non-empty")) + .await?; + let f: Foo = 42u32.encrypt_into_with_context(cipher, 7u64).await?; + let _: u32 = f.decrypt_into(cipher, NonEmpty::from(7u64)).await?; + Ok(()) +} + +/// A struct encrypted field by field: every field has an inferred context, +/// and the caller's extends all of them. +struct User { + age: u32, + email: String, +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + age: Foo, + email: StackCipherText, +} + +async fn user(cipher: &Cipher<'_>, user: User, id: u64) -> Result<(), stack_encrypt::Error> { + // "users/age", "users/email". + let r: EncryptedUser = user.encrypt_into(cipher).await?; + let user = User::decrypt_from(r, cipher).await?; + // ("users/age", id), ("users/email", id). + let r: EncryptedUser = user.encrypt_into_with_context(cipher, id).await?; + let _: EqualityTerm = 42u32 + .encrypt_into_with_context(cipher, nonempty!("users/age").with(id)) + .await?; + let _ = User::decrypt_from_with_context(r, cipher, id).await?; + Ok(()) +} + +fn main() { + let _ = pinned; + let _ = foo; + let _ = user; +} diff --git a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.rs b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.rs new file mode 100644 index 000000000..89c5d97ff --- /dev/null +++ b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.rs @@ -0,0 +1,11 @@ +use stack_encrypt::{EncryptFrom, StackCipherText, StackCipher, nonempty}; +use stack_kms::FakeDataKeySource; +#[derive(Clone, serde::Serialize)] +struct SerdeOnly { value: String } +#[derive(EncryptFrom)] +struct Target { c: StackCipherText } +fn wrong(cipher:&StackCipher<FakeDataKeySource>) { + let keyset=cipher.default_keyset(); + let _=keyset.encrypt_as::<_,Target>(&SerdeOnly {value:"x".into()},nonempty!("column").into()); +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr new file mode 100644 index 000000000..0689cd880 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr @@ -0,0 +1,34 @@ +error[E0277]: the trait bound `SerdeOnly: Encrypt` is not satisfied + --> tests/ui/plaintext_needs_encrypt.rs:9:33 + | +9 | let _=keyset.encrypt_as::<_,Target>(&SerdeOnly {value:"x".into()},nonempty!("column").into()); + | ---------- ^^^^^^ unsatisfied trait bound + | | + | required by a bound introduced by this call + | +help: the trait `Encrypt` is not implemented for `SerdeOnly` + --> tests/ui/plaintext_needs_encrypt.rs:4:1 + | +4 | struct SerdeOnly { value: String } + | ^^^^^^^^^^^^^^^^ + = help: the following other types implement trait `Encrypt`: + &str + ContextTag<Tag, T> + FfiValue + HashMap<K, T> + Vec<T> + Vec<u8> + [u8; N] + stack_encrypt::Element<T> + and $N others + = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `EncryptFrom<SerdeOnly>` + = note: 1 redundant requirement hidden + = note: required for `Target` to implement `EncryptFrom<SerdeOnly>` +note: required by a bound in `target::operations::<impl KeysetCipher<'_, K>>::encrypt_as` + --> src/target/operations.rs + | + | pub fn encrypt_as<'a, S, T>(&'a self, source: &S, context: T::Context) -> Pending<'a, T, K> + | ---------- required by a bound in this associated function + | where + | T: EncryptFrom<S>, + | ^^^^^^^^^^^^^^ required by this bound in `target::operations::<impl KeysetCipher<'_, K>>::encrypt_as` diff --git a/packages/stack-encrypt/tests/ui/reference_plaintext.rs b/packages/stack-encrypt/tests/ui/reference_plaintext.rs new file mode 100644 index 000000000..c27486005 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/reference_plaintext.rs @@ -0,0 +1,9 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +#[stash(plaintext = &str)] +struct Text { + c: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/reference_plaintext.stderr b/packages/stack-encrypt/tests/ui/reference_plaintext.stderr new file mode 100644 index 000000000..3403bab41 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/reference_plaintext.stderr @@ -0,0 +1,5 @@ +error: `plaintext` must be an owned type: a reference plaintext has no lifetime the generated impl can name. Omit `plaintext` for an impl generic over the source, which accepts references too. + --> tests/ui/reference_plaintext.rs:4:21 + | +4 | #[stash(plaintext = &str)] + | ^^^^ diff --git a/packages/stack-encrypt/tests/ui/struct_field_missing.rs b/packages/stack-encrypt/tests/ui/struct_field_missing.rs new file mode 100644 index 000000000..8d56354ba --- /dev/null +++ b/packages/stack-encrypt/tests/ui/struct_field_missing.rs @@ -0,0 +1,16 @@ +//! A field is derived from the plaintext field of its own name; a name the +//! plaintext does not have is reported by rustc at the field. +use stack_encrypt::{EncryptFrom, StackCipherText}; + +struct User { + email: String, +} + +#[derive(EncryptFrom)] +#[stash(struct = User, context = "user")] +struct EncryptedUser { + email: StackCipherText, + nickname: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/struct_field_missing.stderr b/packages/stack-encrypt/tests/ui/struct_field_missing.stderr new file mode 100644 index 000000000..0f741b9f9 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/struct_field_missing.stderr @@ -0,0 +1,7 @@ +error[E0609]: no field `nickname` on type `&User` + --> tests/ui/struct_field_missing.rs:13:5 + | +13 | nickname: StackCipherText, + | ^^^^^^^^ unknown field + | + = note: available field is: `email` diff --git a/packages/stack-encrypt/tests/ui/struct_with_plaintext.rs b/packages/stack-encrypt/tests/ui/struct_with_plaintext.rs new file mode 100644 index 000000000..cbca37a4c --- /dev/null +++ b/packages/stack-encrypt/tests/ui/struct_with_plaintext.rs @@ -0,0 +1,13 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; + +struct User { + email: String, +} + +#[derive(EncryptFrom)] +#[stash(struct = User, plaintext = User)] +struct EncryptedUser { + email: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/struct_with_plaintext.stderr b/packages/stack-encrypt/tests/ui/struct_with_plaintext.stderr new file mode 100644 index 000000000..5253cd712 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/struct_with_plaintext.stderr @@ -0,0 +1,11 @@ +error: `struct` and `plaintext` are two ways of naming the plaintext: `struct = ..` encrypts it field by field, `plaintext = ..` as one value, so give one of them + --> tests/ui/struct_with_plaintext.rs:8:18 + | +8 | #[stash(struct = User, plaintext = User)] + | ^^^^ + +error: `plaintext` given here + --> tests/ui/struct_with_plaintext.rs:8:36 + | +8 | #[stash(struct = User, plaintext = User)] + | ^^^^ diff --git a/packages/stack-encrypt/tests/ui/struct_without_context.rs b/packages/stack-encrypt/tests/ui/struct_without_context.rs new file mode 100644 index 000000000..f24c3af8a --- /dev/null +++ b/packages/stack-encrypt/tests/ui/struct_without_context.rs @@ -0,0 +1,18 @@ +//! `struct = ..` requires an explicit container `context = ".."`: the prefix +//! is part of the stored data's identity — the AAD of every ciphertext +//! derived from the struct and the domain of every term — so it is never +//! inferred from the Rust type's name. Two types named `Account` in +//! different modules must not silently share every field context. +use stack_encrypt::{EncryptFrom, StackCipherText}; + +struct User { + email: String, +} + +#[derive(EncryptFrom)] +#[stash(struct = User)] +struct EncryptedUser { + email: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/struct_without_context.stderr b/packages/stack-encrypt/tests/ui/struct_without_context.stderr new file mode 100644 index 000000000..7b90fe19d --- /dev/null +++ b/packages/stack-encrypt/tests/ui/struct_without_context.stderr @@ -0,0 +1,5 @@ +error: `struct = ..` needs a `context = ".."` beside it naming the stored data (e.g. `#[stash(struct = User, context = "users")]`): each field is derived under `"<context>/<field>"`, and the prefix is part of the stored data's identity, so it is given explicitly rather than inferred from the Rust type's name + --> tests/ui/struct_without_context.rs:13:18 + | +13 | #[stash(struct = User)] + | ^^^^ diff --git a/packages/stack-encrypt/tests/ui/wrong_target_context.rs b/packages/stack-encrypt/tests/ui/wrong_target_context.rs new file mode 100644 index 000000000..bf92d4763 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/wrong_target_context.rs @@ -0,0 +1,14 @@ +use stack_encrypt::{EncryptFrom, StackCipherText, StackCipher, nonempty}; +use stack_kms::FakeDataKeySource; +#[derive(EncryptFrom)] +#[stash(plaintext = String)] +struct Target { + #[stash(context_field)] + identifier: u32, + c: StackCipherText, +} +fn wrong(cipher: &StackCipher<FakeDataKeySource>) { + let keyset = cipher.default_keyset(); + let _ = keyset.encrypt_as::<_,Target>(&"value".to_owned(), nonempty!("wrong type")); +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/wrong_target_context.stderr b/packages/stack-encrypt/tests/ui/wrong_target_context.stderr new file mode 100644 index 000000000..106371dc0 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/wrong_target_context.stderr @@ -0,0 +1,9 @@ +error[E0308]: mismatched types + --> tests/ui/wrong_target_context.rs:12:64 + | +12 | let _ = keyset.encrypt_as::<_,Target>(&"value".to_owned(), nonempty!("wrong type")); + | ^^^^^^^^^^^^^^^^^^^^^^^ expected `NonEmpty<u32>`, found `NonEmpty<&str>` + | + = note: expected struct `NonEmpty<u32>` + found struct `NonEmpty<&'static str>` + = note: this error originates in the macro `nonempty` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/packages/stack-guest-abi/Cargo.toml b/packages/stack-guest-abi/Cargo.toml new file mode 100644 index 000000000..b9c5ecc1b --- /dev/null +++ b/packages/stack-guest-abi/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "stack-guest-abi" +description = "The WASI guest ABI the Go binding's guests share: allocator and buffer registry, status table, host transport import" +version = "0.0.0" +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +license-file = "LICENSE" +# Internal plumbing for the guests under bindings/go, never a release of its +# own; also keeps release-plz from picking it up. +publish = false + +[dependencies] +zeroize = { workspace = true } + +# Only `transport` (wasm32-only) needs these: the import's error type and +# `OpaqueDebug` for its response, whose body can carry wrapped key material +# or a credential and so never prints. Declared for that target alone, so a +# native build carries nothing it does not use and `cargo udeps` sees none. +[target.'cfg(target_arch = "wasm32")'.dependencies] +thiserror = { workspace = true } +vitaminc-protected = { workspace = true } + +[dev-dependencies] +# The registry's property test (`buffers::properties`): random interleavings +# of alloc / register / take / dealloc / wipe_all against a model of what the +# host holds, run natively and under Miri. `std` only: the `fork` and +# `timeout` defaults spawn processes and threads Miri cannot interpret. +proptest = { version = "1.7", default-features = false, features = ["std"] } diff --git a/packages/stack-guest-abi/LICENSE b/packages/stack-guest-abi/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-guest-abi/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + +<https://polyformproject.org/licenses/internal-use/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-guest-abi/src/abi.rs b/packages/stack-guest-abi/src/abi.rs new file mode 100644 index 000000000..5637b4a7f --- /dev/null +++ b/packages/stack-guest-abi/src/abi.rs @@ -0,0 +1,173 @@ +//! The part of the wasm export surface every guest has, and the helpers a +//! guest's own exports are written with. Same conventions as the vitaminc +//! guest (`vc_*`), under the `se_` prefix: +//! +//! - The host owns all buffer lifecycles. It writes inputs into guest +//! memory obtained from [`se_alloc`] and releases every buffer — its own +//! inputs and the guest's outputs — with [`se_dealloc`], which **zeroizes +//! before freeing**. The guest keeps a registry of every buffer it hands +//! out ([`crate::buffers`]), so `se_dealloc` never trusts the host's +//! length. +//! - An export handed plaintext wipes that buffer in place before it +//! returns ([`take_plaintext`]), rather than leaving it for `se_dealloc`: +//! the host's plaintext then lives no longer than the call. A host must +//! not read such a buffer back after the call, or pass it to two calls. +//! - Output buffers that contain plaintext are the host's to copy out and +//! immediately `se_dealloc`. +//! - During an export the host's imported functions may re-enter the guest +//! **only** through `se_alloc` (to place a response); calling any other +//! export from inside a host import is undefined behaviour of the +//! embedding, not of this crate. +//! +//! # Result encoding +//! +//! Every fallible export returns a single `u64` split into a high and a low +//! 32-bit field: +//! +//! - **success** — the high 32 bits are non-zero: an output pointer with +//! the low 32 bits its length ([`ok_buffer`]). +//! - **error** — the high 32 bits are zero and the low 32 bits are a +//! [`crate::status`] code ([`err_status`]). A valid pointer is never zero, +//! so the two spaces never collide. +//! +//! # Hostile-input posture +//! +//! As the vitaminc guest: every export validates its pointer/length pairs +//! against linear memory before any unsafe construction ([`input`]; null +//! with nonzero length rejected), and invalid input yields `STATUS_ENCODING` +//! rather than a trap. A guest wraps each of its *own* exports in a +//! `catch_unwind`, belt-and-braces for a hypothetical unwind build — +//! wasm32-wasip1 aborts on panic; the two exports here need none, since +//! neither has a panic path (allocation goes through `try_reserve_exact` +//! and release through the registry). Statuses are the only detail leaked. +//! +//! Wasm modules are single-threaded; the host must serialize calls into one +//! instance. +//! +//! These exports are `#[no_mangle]` in a library crate: a cdylib that links +//! this crate exports them, so every guest gets `se_alloc` and `se_dealloc` +//! by depending on it and defines only the exports that are its own. + +use zeroize::{Zeroize, Zeroizing}; + +use crate::buffers; +use crate::status::STATUS_ENCODING; + +/// Allocate `len` bytes of guest memory for the host to write into. Returns +/// null if the allocation fails (recoverable host-side; never a trap). +#[no_mangle] +pub extern "C" fn se_alloc(len: u32) -> *mut u8 { + buffers::alloc(len as usize) +} + +/// Zeroize and free a buffer previously handed out by [`se_alloc`] or +/// packed into a result. See [`buffers::dealloc`] for the registry +/// discipline (unknown pointer: no-op; length mismatch: refused). +/// +/// # Safety +/// +/// `ptr` should be a pointer this crate handed out; the registry makes +/// anything else a no-op rather than undefined behaviour. +#[no_mangle] +pub unsafe extern "C" fn se_dealloc(ptr: *mut u8, len: u32) { + unsafe { buffers::dealloc(ptr, len as usize) } +} + +/// Pack a buffer result: `ptr << 32 | len`. The buffer is registered so the +/// host's eventual [`se_dealloc`] wipes and frees exactly what was +/// allocated. +pub fn ok_buffer(out: Vec<u8>) -> u64 { + let len = out.len() as u64; + let ptr = buffers::register(out) as usize as u64; + (ptr << 32) | len +} + +/// Pack an error: the status in the low 32 bits, high bits zero. +pub fn err_status(status: u32) -> u64 { + status as u64 +} + +/// Current linear-memory size in bytes. `u64` because a full 4 GiB memory +/// (65536 pages) overflows a 32-bit `usize`. +fn linear_memory_bytes() -> u64 { + core::arch::wasm32::memory_size::<0>() as u64 * 65536 +} + +/// Borrow a host-supplied `(ptr, len)` pair, validating before any slice +/// exists: null-with-nonzero-length is rejected (treating it as empty would +/// silently drop whatever bytes the host meant to pass), the length must be +/// under `isize::MAX`, and the whole range must lie inside the current +/// linear memory. A pair that fails validation yields `STATUS_ENCODING`; a +/// pair that passes can still name the wrong bytes — the host owns its +/// pointers — but can never fault or over-read past linear memory. +/// +/// # Safety +/// +/// The bounds check is what keeps the read inside linear memory; it cannot +/// see who owns the range or for how long, and that is what the caller +/// promises. `ptr`/`len` must name a buffer the host wrote and still owns +/// (one it obtained from [`se_alloc`], or an empty range), and that buffer +/// must stay allocated and unwritten for the whole of `'a` — in practice, +/// the borrow must end before the export returns and before any wipe of an +/// overlapping range ([`wipe_input`], [`take_plaintext`]). The lifetime is +/// otherwise unconstrained, so a caller choosing `'static` over a range it +/// is about to free would read freed memory: that is the promise, not a +/// property this function can check. +pub unsafe fn input<'a>(ptr: *const u8, len: u32) -> Result<&'a [u8], u32> { + let len = len as usize; + if len == 0 { + return Ok(&[]); + } + if ptr.is_null() || len > isize::MAX as usize { + return Err(STATUS_ENCODING); + } + let end = (ptr as usize).checked_add(len).ok_or(STATUS_ENCODING)?; + if end as u64 > linear_memory_bytes() { + return Err(STATUS_ENCODING); + } + // SAFETY: non-null, in-bounds of linear memory, and under `isize::MAX`; + // wasm linear memory is fully initialized (fresh pages are zero), so + // reading the range as bytes is defined. That it stays allocated and + // unwritten for `'a` is the caller's contract, above. + Ok(unsafe { std::slice::from_raw_parts(ptr, len) }) +} + +/// Zeroize a validated input range in place (without freeing it — the host +/// still owns the buffer and will `se_dealloc` it after the call). +/// +/// # Safety +/// +/// The range must have passed [`input`] validation and carry no outstanding +/// borrows. +pub unsafe fn wipe_input(ptr: *mut u8, len: u32) { + if ptr.is_null() || len == 0 { + return; + } + unsafe { std::slice::from_raw_parts_mut(ptr, len as usize) }.zeroize(); +} + +/// Take a plaintext input out of the host's buffer and wipe the buffer. +/// +/// An export that holds its decoded value across a host round trip would +/// otherwise keep the borrow of the host buffer alive for the whole call. +/// Copying into a `Zeroizing` first lets the original be wiped immediately: +/// the plaintext then exists for the duration of the call and no longer, +/// instead of sitting in linear memory until the host gets round to +/// `se_dealloc`. +/// +/// Call it before any other buffer is borrowed, deliberately. The wipe +/// writes through `&mut`, so no other `&[u8]` into linear memory may be +/// live — and a host that aliases its value range onto another argument +/// therefore reads zeros there, which that argument's parser rejects. +/// +/// # Safety +/// +/// `ptr`/`len` must name a host buffer the caller is done with; it is zeroed +/// before this returns. +pub unsafe fn take_plaintext(ptr: *mut u8, len: u32) -> Result<Zeroizing<Vec<u8>>, u32> { + // SAFETY: the borrow lives only for the copy on this line, inside the + // call, over a buffer the caller has promised is the host's and live. + let taken = Zeroizing::new(unsafe { input(ptr, len)? }.to_vec()); + unsafe { wipe_input(ptr, len) }; + Ok(taken) +} diff --git a/packages/stack-guest-abi/src/buffers.rs b/packages/stack-guest-abi/src/buffers.rs new file mode 100644 index 000000000..62aa430d4 --- /dev/null +++ b/packages/stack-guest-abi/src/buffers.rs @@ -0,0 +1,442 @@ +//! The guest-owned buffer registry behind `se_alloc` / `se_dealloc`, +//! following the vitaminc guest's conventions (see `vcencrypt/guest/src/ +//! abi.rs`). Shared by every guest under `bindings/go`, so the registry +//! discipline is written once: +//! +//! - Every buffer the guest hands out — from [`alloc`] and from packed +//! results — is recorded here keyed by start address, holding the true +//! length. [`dealloc`] consults the registry instead of trusting the +//! host: an unknown pointer (including a double-free) is a no-op, a +//! length mismatch refuses to free, and the zeroizing wipe always covers +//! the true allocation. +//! - [`take`] is the extra move this guest needs beyond vitaminc's: the +//! host *returns* buffers to the guest (the transport response, the +//! token) by writing into `se_alloc`'d memory and handing back the +//! pointer; `take` reclaims ownership under the same registry discipline. +//! +//! Wasm is single-threaded, so a thread-local `RefCell` is a plain owner of +//! the map — no `Send`/`Sync` bounds required. That premise is the one +//! thing in this crate a native cdylib backend (CIP-3997's original scope, +//! deferred) could not keep: a library called from several host threads +//! would register on one thread and release on another, and a release the +//! registry does not know is a silent no-op that never wipes. The backend +//! replaces this owner with a `Mutex`-guarded table; the entry points and +//! their discipline stay as they are. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; + +use zeroize::Zeroize; + +thread_local! { + /// Live sized buffers, keyed by the pointer itself (hashed and compared + /// by address) rather than by `ptr as usize`: the value handed back to + /// `Vec::from_raw_parts` must be the pointer that came out of + /// `Box::into_raw`, provenance intact. Rebuilding it from an integer is + /// an exposed-provenance round trip that strict-provenance Miri + /// (`miri:stack-guest-abi`) rejects, and it would hide a stale entry + /// behind a pointer Miri could no longer check. + static BUFFERS: RefCell<HashMap<*mut u8, usize>> = RefCell::new(HashMap::new()); + /// Live zero-length buffers, counted rather than keyed: every empty + /// `Vec` leaks to the *same* dangling pointer (alignment, so `0x1`), and + /// a pointer-keyed map entry would be overwritten by the second empty + /// allocation — the first reclaim would then remove the only entry and + /// the second would fail validation. A host holding an empty response + /// header buffer and an empty body buffer at once is contract-compliant, + /// so empties get identity-free accounting. + static EMPTY_BUFFERS: Cell<usize> = const { Cell::new(0) }; +} + +/// The pointer every zero-length buffer presents to the host: non-null (null +/// still unambiguously means "allocation failed") and identical for all of +/// them, which is exactly what `Box<[u8]>` produces for an empty slice. +fn empty_ptr() -> *mut u8 { + std::ptr::NonNull::<u8>::dangling().as_ptr() +} + +/// Allocate `len` bytes of guest memory for the host to write into. +/// Returns a pointer valid until reclaimed by [`dealloc`] or [`take`], or +/// null if the allocation fails. The null branch is real: allocation goes +/// through `try_reserve_exact`, not the aborting global-allocator error +/// path, so an oversized request is a recoverable host-side error instead +/// of a trap that poisons the instance. +pub fn alloc(len: usize) -> *mut u8 { + let mut buf: Vec<u8> = Vec::new(); + if buf.try_reserve_exact(len).is_err() { + return core::ptr::null_mut(); + } + buf.resize(len, 0); + register(buf) +} + +/// Register a buffer and leak it to a raw pointer for the host. The +/// registry entry is what makes the matching [`dealloc`] / [`take`] sound. +pub fn register(buf: Vec<u8>) -> *mut u8 { + if buf.is_empty() { + // See `EMPTY_BUFFERS`: empties share one pointer, so they are + // counted, not keyed. Nothing leaks — an empty `Vec` owns no heap. + EMPTY_BUFFERS.with(|c| c.set(c.get() + 1)); + return empty_ptr(); + } + let boxed = buf.into_boxed_slice(); + let len = boxed.len(); + let ptr = Box::into_raw(boxed) as *mut u8; + // A fresh allocation can't already be registered; the returned previous + // entry is the invariant, checked in debug builds. + let previous = BUFFERS.with(|b| b.borrow_mut().insert(ptr, len)); + debug_assert!(previous.is_none()); + ptr +} + +/// Zeroize and free a buffer previously handed out. The registry supplies +/// the true length; `len` is cross-checked but never trusted. An unknown +/// pointer (including a double-free) is a no-op; a length mismatch means +/// the host's bookkeeping has desynced from ours, so the buffer is kept +/// live and registered rather than freed out from under a confused host. +/// +/// # Safety +/// +/// `ptr` should be a pointer this module handed out. The registry makes any +/// other pointer (or a stale one) a no-op rather than undefined behaviour, +/// but a pointer that happens to alias a *different* live registered buffer +/// of the same length would free that buffer. +pub unsafe fn dealloc(ptr: *mut u8, len: usize) { + if let Some(mut buf) = unsafe { reclaim(ptr, len) } { + buf.zeroize(); + } +} + +/// Take ownership of a buffer the host filled via [`alloc`] and handed back +/// (a transport response, a token). Same registry discipline as +/// [`dealloc`]; returns `None` for an unknown pointer or a length mismatch. +/// A null pointer with zero length is an empty buffer. +/// +/// # Safety +/// +/// As for [`dealloc`]. +pub unsafe fn take(ptr: *mut u8, len: usize) -> Option<Vec<u8>> { + if ptr.is_null() && len == 0 { + return Some(Vec::new()); + } + unsafe { reclaim(ptr, len) } +} + +/// Wipe and free every buffer the registry still holds — what a guest's +/// shutdown export does after dropping its state, so a host that tears the +/// instance down without releasing an output first still leaves no +/// plaintext behind. +/// Empties carry no bytes; their count is simply reset. +/// +/// This path allocates nothing: the registry is moved out whole (an empty +/// `HashMap` does not allocate) and walked in place, so a shutdown under +/// linear-memory pressure cannot fail before the wipe on an allocation the +/// wipe itself made. +pub fn wipe_all() { + let live = BUFFERS.with(|b| core::mem::take(&mut *b.borrow_mut())); + for (ptr, len) in live { + // SAFETY: every entry was registered by `register`, which leaked a + // boxed slice of exactly `len` bytes at `ptr`, and it was removed + // above so nothing else can reclaim it. + let mut buf = unsafe { Vec::from_raw_parts(ptr, len, len) }; + buf.zeroize(); + } + EMPTY_BUFFERS.with(|c| c.set(0)); +} + +unsafe fn reclaim(ptr: *mut u8, len: usize) -> Option<Vec<u8>> { + if ptr.is_null() { + return None; + } + if len == 0 && ptr == empty_ptr() { + // An empty reclaim spends one unit of the empty count; over-reclaim + // (a double-free of an empty) fails validation like any other + // unknown pointer. A *real* buffer can never live at the dangling + // address, and a zero `len` can only ever refer to an empty, so the + // two accounting schemes cannot cross. + return EMPTY_BUFFERS.with(|c| { + let live = c.get(); + (live > 0).then(|| { + c.set(live - 1); + Vec::new() + }) + }); + } + // The entry's own key is what the buffer is rebuilt from, not the host's + // copy of the address: the key is the pointer `register` leaked, so it is + // the one with provenance over the allocation. The host's `ptr` only + // selects the entry (pointers hash and compare by address). + let (ptr, real_len) = BUFFERS.with(|b| b.borrow_mut().remove_entry(&ptr))?; + if real_len != len { + // Put the entry back exactly as it was; it was just removed, so + // nothing can be there to displace. + let previous = BUFFERS.with(|b| b.borrow_mut().insert(ptr, real_len)); + debug_assert!(previous.is_none()); + return None; + } + // SAFETY: the registry guarantees `(ptr, real_len)` is exactly one live + // allocation this module handed out via `register` (len == capacity by + // `into_boxed_slice`), and the entry has just been removed so it cannot + // be reclaimed twice. + Some(unsafe { Vec::from_raw_parts(ptr, real_len, real_len) }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Each libtest thread gets its own thread-locals, so tests are isolated. + + #[test] + fn two_live_empty_buffers_reclaim_independently() { + // The regression this pins: both empties present the same dangling + // pointer, and keyed accounting would let the second registration + // clobber the first — making one of these `take`s fail. + let a = alloc(0); + let b = alloc(0); + assert!(!a.is_null() && !b.is_null()); + assert_eq!(unsafe { take(a, 0) }, Some(Vec::new())); + assert_eq!(unsafe { take(b, 0) }, Some(Vec::new())); + // Both spent: a third reclaim is a double-free and must fail. + assert_eq!(unsafe { take(b, 0) }, None); + } + + #[test] + fn empty_and_sized_buffers_do_not_cross_accounts() { + let empty = alloc(0); + let sized = alloc(3); + // A zero-length reclaim of the sized pointer is a length mismatch, + // not a withdrawal from the empty count. + assert_eq!(unsafe { take(sized, 0) }, None); + assert_eq!(unsafe { take(empty, 0) }, Some(Vec::new())); + assert_eq!(unsafe { take(sized, 3) }, Some(vec![0, 0, 0])); + } + + /// Shutdown's invariant: after `wipe_all`, nothing the registry handed + /// out is live — sized or empty — so a host that forgot to release an + /// output cannot reclaim it, and the bytes were zeroized on the way + /// out. + #[test] + fn wipe_all_leaves_no_live_buffer() { + let sized = register(vec![7, 7, 7]); + let host_written = alloc(2); + let empty = alloc(0); + + wipe_all(); + + assert_eq!(unsafe { take(sized, 3) }, None); + assert_eq!(unsafe { take(host_written, 2) }, None); + assert_eq!(unsafe { take(empty, 0) }, None); + // The registry is usable afterwards: a fresh allocation is tracked + // as before. + let again = alloc(1); + assert_eq!(unsafe { take(again, 1) }, Some(vec![0])); + } + + #[test] + fn a_null_pointer_with_zero_length_is_the_canonical_empty() { + assert_eq!(unsafe { take(core::ptr::null_mut(), 0) }, Some(Vec::new())); + } + + #[test] + fn dealloc_of_an_empty_buffer_is_balanced() { + let a = alloc(0); + unsafe { dealloc(a, 0) }; + // The dealloc spent the only live empty; a take now finds none. + assert_eq!(unsafe { take(a, 0) }, None); + } +} + +/// The registry against a model of what the host holds, over random +/// interleavings of every entry point. The unit tests above each pin one +/// rule; this is where the rules are checked together — a length mismatch +/// that leaves the buffer live, a double reclaim that fails, an empty +/// count that cannot be spent on a sized pointer, a wipe that leaves +/// nothing reclaimable — on sequences no one wrote by hand. Under Miri +/// (`miri:stack-guest-abi`) every `from_raw_parts` round trip in those +/// sequences is checked for undefined behaviour as well. +#[cfg(test)] +mod properties { + use super::*; + use proptest::prelude::*; + + /// One host action. `which` selects among the live buffers by modulus, + /// so a shrunk sequence stays meaningful; `len_delta` is the host's + /// error in the length it reports. + #[derive(Debug, Clone)] + enum Op { + /// `se_alloc`, then the host writes a pattern into the buffer. + Alloc(usize), + /// A guest output packed for the host. + Register(Vec<u8>), + Take { + which: usize, + len_delta: i8, + }, + Dealloc { + which: usize, + len_delta: i8, + }, + /// A reclaim of memory the registry never handed out. + TakeForeign(usize), + WipeAll, + } + + fn op() -> impl Strategy<Value = Op> { + prop_oneof![ + (0usize..=48).prop_map(Op::Alloc), + proptest::collection::vec(any::<u8>(), 0..48).prop_map(Op::Register), + (any::<usize>(), -2i8..=2).prop_map(|(which, len_delta)| Op::Take { which, len_delta }), + (any::<usize>(), -2i8..=2) + .prop_map(|(which, len_delta)| Op::Dealloc { which, len_delta }), + (0usize..=8).prop_map(Op::TakeForeign), + Just(Op::WipeAll), + ] + } + + /// What the host holds: a pointer it was handed, the length it was + /// told, and the bytes it expects back. Empties all share one pointer + /// and appear once per live empty, which is the count the registry + /// keeps. + #[derive(Debug)] + struct Held { + ptr: *mut u8, + len: usize, + contents: Vec<u8>, + } + + /// The length the host reports: its true length plus its error, never + /// negative. + fn reported(len: usize, delta: i8) -> usize { + len.saturating_add_signed(delta as isize) + } + + /// Nothing the host holds is reclaimable: every pointer, at its true + /// length, is refused. Called only right after the buffers were + /// released, before any allocation could reuse an address. + fn assert_none_reclaimable(held: &[Held]) -> Result<(), TestCaseError> { + for h in held { + prop_assert_eq!(unsafe { take(h.ptr, h.len) }, None); + } + Ok(()) + } + + proptest! { + #![proptest_config(ProptestConfig { + // Miri runs each case a few hundred times slower; the shape of + // the sequences matters more than their number there. + cases: if cfg!(miri) { 24 } else { 256 }, + // No regression file: the test runs under Miri's isolation. + failure_persistence: None, + ..ProptestConfig::default() + })] + + #[test] + fn the_registry_matches_the_model(ops in proptest::collection::vec(op(), 1..40)) { + let mut held: Vec<Held> = Vec::new(); + + for op in ops { + match op { + Op::Alloc(len) => { + let ptr = alloc(len); + prop_assert!(!ptr.is_null()); + let contents: Vec<u8> = + (0..len).map(|i| (i as u8).wrapping_mul(31)).collect(); + if len == 0 { + prop_assert_eq!(ptr, empty_ptr()); + } else { + // A fresh sized buffer never aliases a live one. + prop_assert!(held.iter().all(|h| h.ptr != ptr)); + // The host writes its input. + unsafe { std::slice::from_raw_parts_mut(ptr, len) } + .copy_from_slice(&contents); + } + held.push(Held { ptr, len, contents }); + } + Op::Register(bytes) => { + let len = bytes.len(); + let ptr = register(bytes.clone()); + prop_assert!(!ptr.is_null()); + if len == 0 { + prop_assert_eq!(ptr, empty_ptr()); + } else { + prop_assert!(held.iter().all(|h| h.ptr != ptr)); + } + held.push(Held { ptr, len, contents: bytes }); + } + Op::Take { which, len_delta } => { + if held.is_empty() { + // With nothing live, the shared empty pointer is + // as unknown as any other. + prop_assert_eq!(unsafe { take(empty_ptr(), 0) }, None); + continue; + } + let i = which % held.len(); + let len = reported(held[i].len, len_delta); + let got = unsafe { take(held[i].ptr, len) }; + if len == held[i].len { + let h = held.swap_remove(i); + prop_assert_eq!(got, Some(h.contents)); + } else { + // Refused, and still live: the true length + // reclaims it next. + prop_assert_eq!(got, None); + } + } + Op::Dealloc { which, len_delta } => { + if held.is_empty() { + unsafe { dealloc(empty_ptr(), 0) }; + prop_assert_eq!(unsafe { take(empty_ptr(), 0) }, None); + continue; + } + let i = which % held.len(); + let len = reported(held[i].len, len_delta); + unsafe { dealloc(held[i].ptr, len) }; + if len == held[i].len { + let h = held.swap_remove(i); + // Freed: a second release at the true length is + // a double-free the registry refuses. Checked + // before anything can reuse the address. + if h.len > 0 || !held.iter().any(|o| o.len == 0) { + prop_assert_eq!(unsafe { take(h.ptr, h.len) }, None); + } + } else { + // A mismatch frees nothing. + let h = &held[i]; + prop_assert_eq!(unsafe { take(h.ptr, h.len) }, Some(h.contents.clone())); + held.swap_remove(i); + } + } + Op::TakeForeign(len) => { + // Memory the registry never saw: refused, and left + // exactly as it was (Miri would report the free). + let mut foreign = vec![0xA5u8; len + 1]; + prop_assert_eq!(unsafe { take(foreign.as_mut_ptr(), len + 1) }, None); + prop_assert_eq!(unsafe { take(foreign.as_mut_ptr(), 0) }, None); + prop_assert!(foreign.iter().all(|&b| b == 0xA5)); + } + Op::WipeAll => { + wipe_all(); + assert_none_reclaimable(&held)?; + held.clear(); + // Usable afterwards. + let again = alloc(1); + prop_assert_eq!(unsafe { take(again, 1) }, Some(vec![0])); + } + } + } + + // Shutdown: everything the host still holds is released, and + // nothing leaks (Miri checks that too). + wipe_all(); + assert_none_reclaimable(&held)?; + } + + /// A null pointer with zero length is the canonical empty whatever + /// else is live, and null with a length is never a buffer. + #[test] + fn null_is_only_ever_the_canonical_empty(len in 1usize..64) { + prop_assert_eq!(unsafe { take(core::ptr::null_mut(), 0) }, Some(Vec::new())); + prop_assert_eq!(unsafe { take(core::ptr::null_mut(), len) }, None); + } + } +} diff --git a/packages/stack-guest-abi/src/headers.rs b/packages/stack-guest-abi/src/headers.rs new file mode 100644 index 000000000..6fb701dac --- /dev/null +++ b/packages/stack-guest-abi/src/headers.rs @@ -0,0 +1,99 @@ +//! The header micro-format of the `transport_send` host import. +//! +//! Request and response headers cross the boundary as one UTF-8 buffer of +//! `name: value` lines separated by `\n` (HTTP/1.1 field syntax, minus +//! folding) — trivially encoded and decoded on both sides without pulling a +//! value codec into the transport layer. Names compare +//! ASCII-case-insensitively, as in HTTP. Pure functions, unit-tested on the +//! native target. The Go host's `parseHeaders` / `encodeHeaders` are the +//! other side of this format. + +/// Encode header pairs as the wire buffer. +pub fn encode_headers(headers: &[(&str, &str)]) -> Vec<u8> { + let mut out = String::new(); + for (i, (name, value)) in headers.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str(name); + out.push_str(": "); + out.push_str(value); + } + out.into_bytes() +} + +/// Look up a header by (ASCII-case-insensitive) name in a wire buffer. +/// Malformed lines (no colon, non-UTF-8 bytes) are skipped rather than +/// failing the response: the transport's contract is carried by the status +/// and body, and header parsing must not be a denial-of-service lever. The +/// skip is per *line*, not per buffer — a proxy that emits one raw +/// ISO-8859-1 byte in an unrelated header (a `via`/`server` line, say) must +/// not make the `content-type` lookup fail and with it every call. +pub fn header_value<'a>(buffer: &'a [u8], name: &str) -> Option<&'a str> { + buffer.split(|&b| b == b'\n').find_map(|line| { + let line = std::str::from_utf8(line).ok()?; + let (n, v) = line.split_once(':')?; + n.trim().eq_ignore_ascii_case(name).then(|| v.trim()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_and_matches_case_insensitively() { + let buffer = encode_headers(&[ + ("authorization", "Bearer tok"), + ("content-type", "application/json"), + ]); + assert_eq!( + std::str::from_utf8(&buffer).unwrap(), + "authorization: Bearer tok\ncontent-type: application/json", + "headers encode one per line, lower-cased, without a trailing newline" + ); + assert_eq!( + header_value(&buffer, "Content-Type"), + Some("application/json"), + "a header is found whatever the case it is asked for in" + ); + assert_eq!(header_value(&buffer, "authorization"), Some("Bearer tok")); + assert_eq!(header_value(&buffer, "x-missing"), None); + } + + #[test] + fn tolerates_whitespace_and_skips_malformed_lines() { + assert_eq!( + header_value(b"Content-Type: text/html \ngarbage-line", "content-type"), + Some("text/html"), + "surrounding whitespace is trimmed and a line without a colon is skipped" + ); + assert_eq!( + header_value(b"no colon here", "content-type"), + None, + "a buffer with no well-formed line has no headers" + ); + assert_eq!( + header_value(&[0xff, 0xfe], "content-type"), + None, + "a buffer that is not UTF-8 has no headers" + ); + assert_eq!( + header_value(b"", "content-type"), + None, + "an empty buffer has no headers" + ); + } + + #[test] + fn a_non_utf8_line_does_not_poison_the_other_headers() { + let mut buffer = b"server: pro".to_vec(); + buffer.push(0xe9); // "proxé" in raw ISO-8859-1 + buffer.extend_from_slice(b"\ncontent-type: application/json"); + assert_eq!( + header_value(&buffer, "content-type"), + Some("application/json"), + "a header after a non-UTF-8 line is still found" + ); + } +} diff --git a/packages/stack-guest-abi/src/lib.rs b/packages/stack-guest-abi/src/lib.rs new file mode 100644 index 000000000..fb15536e3 --- /dev/null +++ b/packages/stack-guest-abi/src/lib.rs @@ -0,0 +1,95 @@ +// Security lints — the block `stack-encrypt` and `stack-auth` carry, minus +// `deny(unsafe_code)`: the export surface (`abi`) and the host import +// (`transport`) are `extern "C"` over raw pointers by nature. Every `unsafe` +// block is confined to those two wasm32-only modules and to the buffer +// registry, and documented at the site; `unsafe_op_in_unsafe_fn` keeps each +// one explicit. +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +// Prevent mem::forget from bypassing ZeroizeOnDrop +#![warn(clippy::mem_forget)] +// Prevent accidental data leaks via output +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Relax in tests +#![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))] +// `abi` and `transport` only exist on wasm32, so on a native doc build their +// intra-doc links have nothing to resolve to. The wasm32 doc build +// (`mise run wasm:guest:test`) is where links are enforced. +#![cfg_attr(not(target_arch = "wasm32"), allow(rustdoc::broken_intra_doc_links))] +//! # The guest ABI the Go binding's WASI guests share +//! +//! The Go binding reaches Rust through WASI modules run by wazero: the +//! crypto guest (`bindings/go/stackencrypt/guest`, `stack-encrypt` over a +//! host-provided transport) and, per ADR-0005, the credential guest +//! (`stack-profile` and `stack-auth`). Everything a guest needs that is +//! *not* about what it does — how the host gets bytes in and out, how a +//! result is packed, what a status number means, how an HTTP request +//! crosses to the host — lives here, once, so the memory-hygiene rules are +//! written and fixed in one place and the two guests read identically from +//! the host side. +//! +//! What is here: +//! +//! - [`buffers`] — the guest-owned buffer registry: every buffer handed to +//! the host is recorded with its true length, released through the +//! registry (never on the host's say-so), and zeroized on the way out. +//! - [`abi`] (wasm32 only) — the `se_alloc` / `se_dealloc` exports every +//! guest has, the packed `u64` result encoding, and the hostile-input +//! helpers that validate a host `(ptr, len)` pair against linear memory +//! before any slice exists. +//! - [`status`] — the status table. **One numbering for every guest**: the +//! codes below keep their values for good, and a guest that needs more +//! appends after them. The Go side decodes the table once. +//! - [`transport`] (wasm32 only) — the `cipherstash_transport::transport_send` +//! host import, wrapped so a guest performs one HTTP request as a safe +//! call and gets both response buffers back through the registry. +//! - [`headers`] — the `name: value` line format request and response +//! headers cross the import in. +//! +//! What is deliberately *not* here: anything that names a crate a guest is +//! built over. The ZeroKMS connection over the transport, the token import +//! the crypto guest uses for its phase-1 auth, the mapping from a library's +//! error type onto the status table, and the `user-agent` a guest sends are +//! each guest's own. +//! +//! # Conventions +//! +//! The host owns every buffer lifecycle. It writes inputs into guest memory +//! obtained from `se_alloc` and releases every buffer — its own inputs and +//! the guest's outputs — with `se_dealloc`, which zeroizes before freeing. +//! Every fallible export returns one `u64`: a non-zero high half is an +//! output pointer with the length in the low half; a zero high half carries +//! a [`status`] code in the low half. Wasm modules are single-threaded; the +//! host serializes calls into one instance, and during an export the host's +//! imports may re-enter the guest only through `se_alloc`. +//! +//! The crate builds natively too — the registry and the header format have +//! no wasm in them and are unit-tested on the host — but only the wasm32 +//! build has an ABI: on any other target the export and import modules do +//! not exist, so a native library built over this crate exports no `se_*` +//! symbol at all rather than a silently wrong one (the packed result would +//! truncate a 64-bit pointer). A native backend, when it comes, adds its own +//! export and import modules beside these and swaps the registry's +//! thread-local owner for a locked one (see [`buffers`]); nothing else here +//! assumes wasm. + +pub mod buffers; +pub mod headers; +pub mod status; + +#[cfg(target_arch = "wasm32")] +pub mod abi; +#[cfg(target_arch = "wasm32")] +pub mod transport; diff --git a/packages/stack-guest-abi/src/status.rs b/packages/stack-guest-abi/src/status.rs new file mode 100644 index 000000000..a3b431b33 --- /dev/null +++ b/packages/stack-guest-abi/src/status.rs @@ -0,0 +1,169 @@ +//! The status table: the low 32 bits of a packed error result (see +//! [`crate::abi`]). +//! +//! **One numbering for every guest.** The Go host decodes these values once, +//! in the internal package both public packages share, so a number means the +//! same thing whichever guest reported it. The codes here are part of the +//! guest/host contract and are never renumbered or reused; a guest that needs +//! a status of its own appends after [`LAST_STATUS`], in this file, so the +//! table stays one table. +//! +//! Codes 1–4 are byte-for-byte the vitaminc guest's codes (`vcencrypt`'s +//! `status.rs`), so every guest reads identically from the host side; code 3 +//! there is "unknown handle", and here — where there is no handle — it is +//! the call-order violation that means the same thing to a host: nothing to +//! run this call against. Codes 5–10 are the outcomes of a request to +//! ZeroKMS, so a host can distinguish a refused credential from a tampered +//! ciphertext without parsing strings; 11 and 12 were appended by the +//! crypto guest, 13–19 by the credential guest. +//! +//! Each code is documented here as the *verdict* it carries to a host — the +//! thing the host can act on. Which of a library's errors reach which code, +//! and what each verdict means for a particular export, is each guest's own +//! (`status_for_error` and friends in the crypto guest's `status` module, +//! `status_for_profile` in the credential guest's): this module names the +//! numbers, not the libraries. + +/// AEAD open failure: the ciphertext, or the context it is being opened +/// under, is not what it was sealed with. A tampered ciphertext, a wrong +/// element derivation, or a wrong context that reached the AEAD. +pub const STATUS_AUTH: u32 = 1; +/// Invalid input at the boundary: malformed transport bytes, an input that +/// fails the export's own validation, or a pointer/length pair that fails +/// validation against linear memory. +pub const STATUS_ENCODING: u32 = 2; +/// The call is out of order: an operation before the guest's init export, or +/// after its shutdown, or init twice. A host fixes its call sequence; +/// nothing here is a guest bug. (The vitaminc guest's code 3 is "unknown +/// handle", the same condition under a handle scheme.) +pub const STATUS_STATE: u32 = 3; +/// A caught panic, a response that did not match its requests, or any other +/// unexpected internal failure. +pub const STATUS_INTERNAL: u32 = 4; +/// ZeroKMS (or the auth strategy) rejected the *credential*: an expired or +/// rejected access token, or a credential exchange the server refused. +/// +/// The one status a host should answer by refreshing the token and retrying. +/// Deliberately narrow for that reason: a configuration fault that merely +/// *arrives* through the auth path is [`STATUS_KMS_TRANSPORT`], since no +/// number of refreshes can fix it. +pub const STATUS_KMS_UNAUTHORIZED: u32 = 5; +/// ZeroKMS rejected the request as forbidden: the token is valid but lacks +/// permission, the keyset is disabled, the organisation is over its usage +/// allowance — or the data key requested is bound to a context other than +/// the one presented, so it cannot be re-derived. +pub const STATUS_KMS_FORBIDDEN: u32 = 6; +/// ZeroKMS could not find the resource: an unknown keyset (or client), or a +/// data key that does not exist for the presented `iv`/`tag`. +pub const STATUS_KMS_NOT_FOUND: u32 = 7; +/// ZeroKMS reported a resource conflict. +pub const STATUS_KMS_CONFLICT: u32 = 8; +/// No ZeroKMS verdict was reached: the host's transport failed, the endpoint +/// is unknown or invalid, or the request could not be prepared. +/// +/// Not retryable by refreshing a token — these are configuration or host +/// faults. See [`STATUS_KMS_UNAUTHORIZED`] for the one that is. +pub const STATUS_KMS_TRANSPORT: u32 = 9; +/// ZeroKMS failed in a way none of the codes above capture: a malformed +/// response, invalid key material, or an unclassified server error. +pub const STATUS_KMS_OTHER: u32 = 10; +/// An index term failed to derive from the value it was asked for. +pub const STATUS_TERM: u32 = 11; +/// An opening export was constrained to one keyset and the value named +/// another; refused before any key is retrieved. A constraint failure, and +/// only that — never a verdict on the value's integrity. +pub const STATUS_FOREIGN_KEYSET: u32 = 12; + +// ---- The credential guest's codes (ADR-0005): `stack-profile`'s errors, +// one number each, so a Go caller can tell a missing workspace from a +// malformed file without parsing strings. `HomeDirNotFound` has no code: +// the guest is given its directory and never resolves one. + +/// A profile file could not be read or written: the I/O error underneath +/// `stack_profile::ProfileError::Io`. +pub const STATUS_PROFILE_IO: u32 = 13; +/// A profile file held something other than the JSON its type expects. +pub const STATUS_PROFILE_JSON: u32 = 14; +/// The profile file asked for does not exist: no `secretkey.json`, +/// `auth.json` or `device.json` in that store. +pub const STATUS_PROFILE_NOT_FOUND: u32 = 15; +/// A filename the store refuses: empty, absolute, or naming a path +/// (separators, `..`). Refused before anything is opened. +pub const STATUS_PROFILE_INVALID_FILENAME: u32 = 16; +/// No current workspace is set; a workspace-scoped operation needs one. +pub const STATUS_PROFILE_NO_CURRENT_WORKSPACE: u32 = 17; +/// A workspace id that is not sixteen base32 characters. Refused before +/// any path is built from it. +pub const STATUS_PROFILE_INVALID_WORKSPACE_ID: u32 = 18; +/// The workspace has no directory under `workspaces/`: nothing has logged +/// in to it on this machine. +pub const STATUS_PROFILE_WORKSPACE_NOT_FOUND: u32 = 19; + +// Auth strategy verdicts from the credential guest. Keep the three +// actionable exchange refusals separate from network/configuration errors. +pub const STATUS_AUTH_INVALID_GRANT: u32 = 20; +pub const STATUS_AUTH_INVALID_CLIENT: u32 = 21; +pub const STATUS_AUTH_USAGE_LIMIT: u32 = 22; +pub const STATUS_AUTH_NOT_AUTHENTICATED: u32 = 23; +pub const STATUS_AUTH_TRANSPORT: u32 = 24; +pub const STATUS_AUTH_CONFIG: u32 = 25; +pub const STATUS_AUTH_OTHER: u32 = 26; +/// The device-session token is inside its refresh window. The Go host must +/// acquire the profile lock before calling the credential guest's refresh +/// export; this is a control-flow signal, not a caller-facing auth failure. +pub const STATUS_AUTH_REFRESH_REQUIRED: u32 = 27; + +/// The last code in the table. A guest appending a code of its own starts +/// at `LAST_STATUS + 1` and moves this constant with it, so two guests can +/// never claim one number. +pub const LAST_STATUS: u32 = STATUS_AUTH_REFRESH_REQUIRED; + +#[cfg(test)] +mod tests { + use super::*; + + /// The table is dense from 1 and every code is distinct; a renumbering + /// or a gap would break the Go decoder's contract silently. + #[test] + fn the_table_is_dense_and_distinct() { + let codes = [ + STATUS_AUTH, + STATUS_ENCODING, + STATUS_STATE, + STATUS_INTERNAL, + STATUS_KMS_UNAUTHORIZED, + STATUS_KMS_FORBIDDEN, + STATUS_KMS_NOT_FOUND, + STATUS_KMS_CONFLICT, + STATUS_KMS_TRANSPORT, + STATUS_KMS_OTHER, + STATUS_TERM, + STATUS_FOREIGN_KEYSET, + STATUS_PROFILE_IO, + STATUS_PROFILE_JSON, + STATUS_PROFILE_NOT_FOUND, + STATUS_PROFILE_INVALID_FILENAME, + STATUS_PROFILE_NO_CURRENT_WORKSPACE, + STATUS_PROFILE_INVALID_WORKSPACE_ID, + STATUS_PROFILE_WORKSPACE_NOT_FOUND, + STATUS_AUTH_INVALID_GRANT, + STATUS_AUTH_INVALID_CLIENT, + STATUS_AUTH_USAGE_LIMIT, + STATUS_AUTH_NOT_AUTHENTICATED, + STATUS_AUTH_TRANSPORT, + STATUS_AUTH_CONFIG, + STATUS_AUTH_OTHER, + STATUS_AUTH_REFRESH_REQUIRED, + ]; + for (i, code) in codes.iter().enumerate() { + assert_eq!(*code, i as u32 + 1, "code {i} is out of sequence"); + } + // `LAST_STATUS` is the append point for the next guest, so it must + // be the largest code in the table — a code added to the list above + // without moving it would hand the next guest a number already taken. + assert_eq!(LAST_STATUS, codes.into_iter().max().unwrap_or(0)); + // Zero is never a status: it is the high half of a *successful* + // packed result, and a status of zero would be unrepresentable. + assert!(codes.iter().all(|c| *c != 0)); + } +} diff --git a/packages/stack-guest-abi/src/transport.rs b/packages/stack-guest-abi/src/transport.rs new file mode 100644 index 000000000..586644c71 --- /dev/null +++ b/packages/stack-guest-abi/src/transport.rs @@ -0,0 +1,133 @@ +//! The `cipherstash_transport::transport_send` host import: one HTTP +//! request performed by the host on the guest's behalf. wasm32-only — +//! everything here calls an imported function. +//! +//! # Import contract (module `cipherstash_transport`) +//! +//! All pointers are offsets into guest linear memory; the host allocates +//! guest buffers with `se_alloc` and the guest reclaims them through its +//! registry ([`crate::buffers`]). +//! +//! `transport_send(method, url, headers, body, resp_headers_out, +//! resp_body_out) -> status` — perform one HTTP request. Each of the four +//! inputs is a `(ptr, len)` pair borrowed for the duration of the call; +//! `headers` is the `name: value` line format of [`crate::headers`]. The +//! two outputs are `(ptr_out, len_out)` slot pairs the host fills with +//! `se_alloc`'d buffers (response headers, response body). The return value +//! is the HTTP status code, or negative for a transport-level failure — +//! then the body carries the host's error text and headers are empty. +//! +//! This is #2099's ZeroKMS-shaped import generalised to a plain HTTP +//! request (method + URL + headers), which is why `stack-auth`'s refreshers +//! can run over it too, and a future `wasi:http` implementation can replace +//! it without changing the callers. +//! +//! What crosses the boundary per call is what would cross TLS anyway: the +//! URL, the headers (a bearer credential among them), and the serialized +//! request. Response bodies can carry wrapped keys or tokens, so a +//! [`Response`] wipes its body on drop; the buffers the host wrote are +//! reclaimed via the registry, which the ABI's `se_dealloc` also wipes. + +use vitaminc_protected::OpaqueDebug; +use zeroize::Zeroizing; + +use crate::buffers; + +#[link(wasm_import_module = "cipherstash_transport")] +extern "C" { + fn transport_send( + method_ptr: *const u8, + method_len: u32, + url_ptr: *const u8, + url_len: u32, + headers_ptr: *const u8, + headers_len: u32, + body_ptr: *const u8, + body_len: u32, + resp_headers_ptr_out: *mut u32, + resp_headers_len_out: *mut u32, + resp_body_ptr_out: *mut u32, + resp_body_len_out: *mut u32, + ) -> i32; +} + +/// The host stored an out-slot pointer the guest's buffer registry does not +/// know (or with a mismatched length) — a host-side bookkeeping bug. +#[derive(Debug, thiserror::Error)] +#[error("host returned an unregistered or mismatched buffer")] +pub struct HostBufferError; + +/// What the host handed back for one request, both buffers reclaimed. +/// +/// Only [`send`] builds one, and `Debug` shows the status alone: the body +/// can carry wrapped key material or a credential, and the headers are +/// treated the same way rather than audited per name. +#[derive(OpaqueDebug)] +#[non_exhaustive] +pub struct Response { + /// The HTTP status code, or negative for a transport-level failure — + /// then `body` is the host's error text. Kept as the import returns it: + /// the sign convention is the wire contract with the Go host, and each + /// guest maps it onto its own library's response type. + #[non_sensitive] + pub status: i32, + /// The response headers in the [`crate::headers`] line format. + pub headers: Vec<u8>, + /// The response body. Wiped on drop: it can carry wrapped key material + /// or a credential. + pub body: Zeroizing<Vec<u8>>, +} + +/// Perform one HTTP request through the host. +/// +/// The host call is synchronous from the guest's perspective. The two +/// response slots are reclaimed *before* either is judged: a `?` on the +/// headers slot would otherwise strand the body buffer — a 2xx JSON body +/// full of wrapped data keys — registered, unfreed and unwiped for the life +/// of the instance. A slot the host did not fill (or filled with a pointer +/// the registry does not know) is [`HostBufferError`]. +pub fn send( + method: &str, + url: &str, + headers: &[u8], + body: &[u8], +) -> Result<Response, HostBufferError> { + let mut resp_headers_ptr: u32 = 0; + let mut resp_headers_len: u32 = 0; + let mut resp_body_ptr: u32 = 0; + let mut resp_body_len: u32 = 0; + + // SAFETY: every input pair names a live guest allocation borrowed for + // the call; the out-slots are stack locals the host writes once. + let status = unsafe { + transport_send( + method.as_ptr(), + method.len() as u32, + url.as_ptr(), + url.len() as u32, + headers.as_ptr(), + headers.len() as u32, + body.as_ptr(), + body.len() as u32, + &mut resp_headers_ptr, + &mut resp_headers_len, + &mut resp_body_ptr, + &mut resp_body_len, + ) + }; + + // SAFETY: pointers come from the host's `se_alloc` calls; the registry + // validates them before any Vec is rebuilt. + let resp_headers = + unsafe { buffers::take(resp_headers_ptr as *mut u8, resp_headers_len as usize) }; + let resp_body = unsafe { buffers::take(resp_body_ptr as *mut u8, resp_body_len as usize) } + .map(Zeroizing::new); + + let headers = resp_headers.ok_or(HostBufferError)?; + let body = resp_body.ok_or(HostBufferError)?; + Ok(Response { + status, + headers, + body, + }) +} diff --git a/packages/stack-guest-abi/tasks.toml b/packages/stack-guest-abi/tasks.toml new file mode 100644 index 000000000..b840c2f33 --- /dev/null +++ b/packages/stack-guest-abi/tasks.toml @@ -0,0 +1,30 @@ +# Miri over the guest ABI crate's native half. The buffer registry +# (`buffers.rs`) is the one place under `bindings/go` where raw pointers are +# handed out, kept, and turned back into owned `Vec`s, and its unit tests are +# the only thing that exercises that round trip on a target Miri can +# interpret — the `abi` and `transport` modules are wasm32-only and read the +# wasm `memory_size` intrinsic, which Miri cannot execute; their hostile-input +# behaviour is pinned from the Go side instead (`go:test`). +# +# Strict provenance: the registry may not rebuild a pointer from an integer, +# because a pointer that was only ever an address has no allocation Miri can +# check it against. Keying the map by the pointer itself keeps provenance +# through the round trip, and this flag is what fails the build if that +# discipline slips. +["miri:stack-guest-abi"] +description = "Run the stack-guest-abi unit tests under Miri (nightly) with strict provenance — checks the buffer registry's raw-pointer round trips for undefined behaviour" +env = { MIRIFLAGS = "-Zmiri-strict-provenance" } +run = "cargo +nightly miri test -p stack-guest-abi" + +# Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc +# warning fails the build. Doc *examples* are `test:doc:stack-guest-abi`. Both run +# with all features so nothing feature-gated goes unchecked; the root `doc` +# task fans out over every `doc:<crate>`. +["doc:stack-guest-abi"] +description = "Build docs for stack-guest-abi with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-guest-abi --no-deps --all-features" + +["test:doc:stack-guest-abi"] +description = "Run documentation tests for stack-guest-abi" +run = "mise x --env test -- cargo test -p stack-guest-abi --doc --all-features" diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml new file mode 100644 index 000000000..dae4ddc78 --- /dev/null +++ b/packages/stack-kms/Cargo.toml @@ -0,0 +1,89 @@ +[package] +name = "stack-kms" +description = "Standalone client for ZeroKMS key generation and retrieval" +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" +# Not yet released: keep release-plz from picking this crate up (it processes +# any workspace crate whose Cargo.toml lacks `publish = false`). +publish = false + +[features] +default = ["profile", "http"] +# The default transport: `HttpConnection`, speaking HTTPS via reqwest, and the +# `StackKmsBuilder` that configures it. Off, the crate has no HTTP client and +# no TLS stack in its graph; a host that provides its own transport (the +# WASI/wazero guest, where HTTP is a host import) implements +# `ZeroKMSConnection` itself and builds the client with `StackKms::connect`. +http = ["dep:reqwest", "dep:lazy_static", "stack-auth/http"] +# Loads the client key from the CLI's on-disk profile (`secretkey.json`, via +# `stack-profile`): `SecretKey: ProfileData` and `KeyProvider for ProfileStore`. +# Disable for consumers that only ever source the key in-memory or from the +# environment. Never compiled on wasm32 regardless. +profile = ["dep:stack-profile"] +# Exposes `FakeDataKeySource`, a deterministic in-memory `DataKeySource` for +# downstream crates (e.g. `stack-encrypt`) to unit-test encrypt/decrypt without +# ZeroKMS credentials or network access. +test-support = [] +# Enables HTTP/2 in the reqwest client. ZeroKMS endpoints negotiate h2 via +# ALPN; without it reqwest is HTTP/1.1-only and opens a connection per +# concurrent request (`max_concurrent_reqs`), which can exhaust client-side +# ephemeral ports under load. Off by default while we validate it; intended to +# default-on later. Mirrors the same feature on `cipherstash-client`. +http2 = ["http", "reqwest/http2"] + +[dependencies] +recipher = { workspace = true } +# The workspace entry is already `default-features = false`, so stack-auth's +# `http` feature rides on this crate's `http` feature rather than arriving by +# default. +stack-auth = { workspace = true } +zerokms-protocol = { workspace = true } + +vitaminc = { workspace = true, features = ["protected", "random"] } +# Direct dep required because the `TimingSafeEq` derive macro expands to a +# `::vitaminc_protected` path. +vitaminc-protected = { workspace = true } + +blake3 = { workspace = true } +lazy_static = { workspace = true, optional = true } +miette = { workspace = true } +reqwest = { workspace = true, optional = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } +zeroize = { workspace = true } + +base16ct = { version = "0.2.0", features = ["alloc"] } +base64ct = { version = "1.7", features = ["alloc"] } +futures = "0.3.25" +opaque-debug = "0.3.1" +serde_cbor = "0.11.2" +serdect = { version = "0.3.0", features = ["zeroize"] } +sha2 = "0.10.6" + +# Native-only and behind the `profile` feature: `stack-profile` is the +# filesystem-backed profile/config layer used by `SecretKey` (the on-disk +# `secretkey.json`) and the `ProfileStore` key provider. Wasm consumers source +# the client key in-memory instead. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +stack-profile = { workspace = true, optional = true } + +[dev-dependencies] +async-mutex = "1.4.0" +recipher = { workspace = true } +stack-auth = { workspace = true, features = ["test-utils"] } +tempfile = "3.21.0" +tokio = { workspace = true } +toml = "0.8.19" + +[package.metadata.docs.rs] +all-features = true diff --git a/packages/stack-kms/LICENSE b/packages/stack-kms/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-kms/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + +<https://polyformproject.org/licenses/internal-use/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/fuzz/Cargo.lock b/packages/stack-kms/fuzz/Cargo.lock new file mode 100644 index 000000000..ebe0a0f54 --- /dev/null +++ b/packages/stack-kms/fuzz/Cargo.lock @@ -0,0 +1,3088 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cached" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.5", + "once_cell", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cipherstash-config" +version = "0.42.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d098935e395d7346d0cdc8cdf3ed9674ab03fa8b415e828d02e65c81836a73c" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest 0.10.7", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-hex" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cts-common" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cb0f5ffa463e8facbe6ad78cfe925d132a051c6b1c9a5da2f3961296b7e632" +dependencies = [ + "arrayvec", + "base32", + "cached", + "chrono", + "derive_more", + "either", + "getrandom 0.4.2", + "miette", + "nom", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "utoipa", + "uuid", + "vitaminc", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", + "ctutils", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dummy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fake" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" +dependencies = [ + "deunicode", + "dummy", + "rand 0.8.6", + "uuid", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix", + "windows-targets 0.52.6", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mutants" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.3", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "recipher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e156e2d485b51cc67c19241e7d81ad524bda9fd4f77791b698ab10c8e26e9" +dependencies = [ + "aes", + "cmac", + "getrandom 0.2.17", + "hex", + "hex-literal", + "opaque-debug", + "rand 0.8.6", + "rand_chacha 0.3.1", + "serde", + "serde_cbor", + "sha2", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", + "zeroize", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.42.3" +dependencies = [ + "aquamarine", + "base64", + "cts-common", + "jsonwebtoken", + "miette", + "open", + "serde", + "serde_json", + "serde_urlencoded", + "stack-profile", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "web-time", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-kms" +version = "0.1.0" +dependencies = [ + "base16ct", + "base64ct", + "blake3", + "futures", + "miette", + "opaque-debug", + "recipher", + "serde", + "serde_cbor", + "serde_json", + "serdect", + "sha2", + "stack-auth", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-kms-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "stack-kms", + "uuid", +] + +[[package]] +name = "stack-profile" +version = "0.42.3" +dependencies = [ + "dirs", + "gethostname", + "serde", + "serde_json", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "url", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "atomic", + "getrandom 0.3.4", + "js-sys", + "md-5", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vitaminc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +dependencies = [ + "vitaminc-aead", + "vitaminc-context", + "vitaminc-encrypt", + "vitaminc-protected", + "vitaminc-random", + "vitaminc-traits", +] + +[[package]] +name = "vitaminc-aead" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +dependencies = [ + "bytes", + "serde", + "vitaminc-aead-derive", + "vitaminc-context", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-context" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +dependencies = [ + "mutants", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.18", + "vitaminc-protected-derive", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-random" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random-derives", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-traits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerokms-protocol" +version = "0.12.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c28e88315a5109d0a1e7ee4b7b4b8776a0bff5f5b139ae83960a3debe84e92e" +dependencies = [ + "base64", + "cipherstash-config", + "const-hex", + "cts-common", + "fake", + "getrandom 0.2.17", + "opaque-debug", + "rand 0.8.6", + "serde", + "static_assertions", + "thiserror 1.0.69", + "utoipa", + "uuid", + "validator", + "zeroize", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/packages/stack-kms/fuzz/Cargo.toml b/packages/stack-kms/fuzz/Cargo.toml new file mode 100644 index 000000000..2bbdc1ddf --- /dev/null +++ b/packages/stack-kms/fuzz/Cargo.toml @@ -0,0 +1,35 @@ +# Fuzz crate for stack-kms's public client-key material decoder. +# +# This is a DETACHED crate: the `[workspace]` table at the bottom makes it its +# own workspace root so the libfuzzer-sys dependency and the nightly-only build +# never touch the main monorepo workspace. It is not a member of the root +# workspace (see the root Cargo.toml `members` list). Run via the `fuzz:*` +# mise tasks, which invoke `cargo +nightly fuzz run`. +[package] +name = "stack-kms-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +uuid = "1.8" + +[dependencies.stack-kms] +path = ".." +# The decoder needs neither the HTTP transport nor the on-disk profile, and +# dropping them keeps reqwest and its TLS stack out of the fuzz build. +default-features = false + +[[bin]] +name = "client_key_encoded" +path = "fuzz_targets/client_key_encoded.rs" +test = false +doc = false +bench = false + +[workspace] +resolver = "2" diff --git a/packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-base64 b/packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-base64 new file mode 100644 index 000000000..34d343002 --- /dev/null +++ b/packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-base64 @@ -0,0 +1 @@ +pGJwMaFrcGVybXV0YXRpb26QCwgCBwQGBQMOCgEADQkPDGdwMl9mcm9toWtwZXJtdXRhdGlvbpACAw4LBAANAQgKCQ8HDAUGZXAyX3RvoWtwZXJtdXRhdGlvbpAOAwYMDwkHAgEFBAAKDQgLYnAzoWtwZXJtdXRhdGlvbpghEgwVGBgKFBgbARYCGB8NGCAYHBAYHhgZGBoHGB0TBg8XAwAOBQsRCAkE \ No newline at end of file diff --git a/packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-hex b/packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-hex new file mode 100644 index 000000000..75533b145 --- /dev/null +++ b/packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-hex @@ -0,0 +1 @@ +a4627031a16b7065726d75746174696f6e900b080207040605030e0a01000d090f0c6770325f66726f6da16b7065726d75746174696f6e9002030e0b04000d01080a090f070c05066570325f746fa16b7065726d75746174696f6e900e03060c0f090702010504000a0d080b627033a16b7065726d75746174696f6e9821120c1518180a14181b011602181f0d1820181c10181e1819181a07181d13060f1703000e050b11080904 \ No newline at end of file diff --git a/packages/stack-kms/fuzz/fuzz_targets/client_key_encoded.rs b/packages/stack-kms/fuzz/fuzz_targets/client_key_encoded.rs new file mode 100644 index 000000000..882aac121 --- /dev/null +++ b/packages/stack-kms/fuzz/fuzz_targets/client_key_encoded.rs @@ -0,0 +1,17 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use stack_kms::ClientKey; +use uuid::Uuid; + +// Fuzz the public `ClientKey::from_encoded_v1` decoder: hex (either case) or +// standard padded base64, then CBOR keyset decoding. This is the entry point +// front-ends use for key material arriving from an untyped boundary — an +// environment variable, `secretkey.json`, the WASI guest's FFI config object — +// so parsing must never panic: malformed input must return `Err`, not crash. +// +// The key id is fixed: it is not parsed, only stored, so varying it would just +// dilute the corpus. libfuzzer-sys supplies `&str` via the `arbitrary` crate. +fuzz_target!(|s: &str| { + let _ = ClientKey::from_encoded_v1(Uuid::nil(), s); +}); diff --git a/packages/stack-kms/src/builder.rs b/packages/stack-kms/src/builder.rs new file mode 100644 index 000000000..30c05923a --- /dev/null +++ b/packages/stack-kms/src/builder.rs @@ -0,0 +1,438 @@ +use crate::client::{ + ClientOpts, InvalidClientOpts, StackKms, DEFAULT_CONCURRENT_REQS, DEFAULT_KEYS_PER_REQ, +}; +use crate::connection::HttpConnectionOpts; +use crate::endpoint::{InvalidEndpoint, ZeroKmsEndpoint}; +use crate::key::ClientKey; +use crate::key_provider::{KeyProvider, KeyProviderError}; +use stack_auth::{AuthStrategy, AuthStrategyBounds}; +use thiserror::Error; + +/// Error type for [`StackKmsBuilder`] operations. +#[derive(Debug, Error, miette::Diagnostic)] +pub enum StackKmsBuilderError { + /// Failed to initialize the underlying client. + #[error("Failed to initialize client: {0}")] + ClientInit(#[from] crate::errors::Error), + + /// Authentication strategy failed to initialize. + #[error("Auth strategy error: {0}")] + Auth(#[from] stack_auth::AuthError), + + /// Key provider failed to load a client key. + #[error("Key provider error: {0}")] + KeyProvider(#[from] KeyProviderError), + + /// A builder option was set to an invalid value (e.g. a zero concurrency + /// or keys-per-request limit). + #[error(transparent)] + InvalidConfig(#[from] InvalidClientOpts), + + /// The ZeroKMS endpoint in the named environment variable is not usable. + /// Unlike a missing variable this is not skipped: falling through to the + /// token's `services` claim would silently send key operations somewhere + /// the operator did not configure. + #[error("Invalid ZeroKMS endpoint in {env_var}: {source}")] + InvalidEndpoint { + env_var: &'static str, + #[source] + source: InvalidEndpoint, + }, +} + +/// A builder for creating [`StackKms`] clients. +/// +/// A [`ClientKey`] is **required** — key generation and retrieval can't happen +/// without one — so the terminal [`build`](Self::build) only exists once a key +/// (via [`with_client_key`](Self::with_client_key)) or a +/// [`KeyProvider`](crate::KeyProvider) (via +/// [`with_key_provider`](Self::with_key_provider)) has been supplied. +/// +/// The ZeroKMS endpoint is resolved in this order: +/// 1. Explicit [`ZeroKmsEndpoint`] via [`with_base_url`](Self::with_base_url) +/// 2. `CS_ZEROKMS_HOST` (or legacy `CS_VITUR_HOST`) environment variable — +/// the first one that is *set* is used, and an invalid value is an error +/// 3. Automatically from the token's `services` claim +/// +/// # Example +/// +/// ```no_run +/// use stack_kms::{StackKmsBuilder, ClientKey}; +/// use stack_auth::AutoStrategy; +/// use uuid::Uuid; +/// +/// # fn example() -> Result<(), Box<dyn std::error::Error>> { +/// let strategy = AutoStrategy::detect()?; +/// let client_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000")?; +/// let client_key = ClientKey::from_hex_v1(client_id, "a4627031...")?; +/// +/// let kms = StackKmsBuilder::new(strategy) +/// .with_client_key(client_key) +/// .build()?; +/// # Ok(()) +/// # } +/// ``` +pub struct StackKmsBuilder<C, ClientKeyState = ()> { + credentials: C, + connection: HttpConnectionOpts, + max_keys_per_req: usize, + max_concurrent_reqs: usize, + client_key: ClientKeyState, +} + +impl StackKmsBuilder<stack_auth::AutoStrategy, ()> { + /// Create a [`StackKmsBuilder`] that automatically detects credentials from the environment. + /// + /// ```no_run + /// use stack_kms::StackKmsBuilder; + /// + /// # fn example() -> Result<(), Box<dyn std::error::Error>> { + /// let builder = StackKmsBuilder::auto()?; + /// # Ok(()) + /// # } + /// ``` + pub fn auto() -> Result<Self, StackKmsBuilderError> { + let strategy = stack_auth::AutoStrategy::detect()?; + Ok(Self::new(strategy)) + } +} + +impl<C> StackKmsBuilder<C, ()> +where + C: AuthStrategyBounds, + for<'a> &'a C: AuthStrategy, +{ + /// Create a new [`StackKmsBuilder`]. + /// + /// # Arguments + /// + /// * `credentials` - Credentials provider for obtaining access tokens + pub fn new(credentials: C) -> Self { + Self { + credentials, + connection: HttpConnectionOpts::new(None), + max_keys_per_req: DEFAULT_KEYS_PER_REQ, + max_concurrent_reqs: DEFAULT_CONCURRENT_REQS, + client_key: (), + } + } + + /// Add a [`KeyProvider`] to load a client key asynchronously at build time. + /// + /// This transforms the builder into one that builds via an async + /// [`build()`](StackKmsBuilder::build) call. + pub fn with_key_provider<K: KeyProvider>( + self, + provider: K, + ) -> StackKmsBuilder<C, WithKeyProvider<K>> { + StackKmsBuilder { + credentials: self.credentials, + connection: self.connection, + max_keys_per_req: self.max_keys_per_req, + max_concurrent_reqs: self.max_concurrent_reqs, + client_key: WithKeyProvider(provider), + } + } + + /// Add a client key directly. + pub fn with_client_key(self, client_key: ClientKey) -> StackKmsBuilder<C, ClientKey> { + StackKmsBuilder { + credentials: self.credentials, + connection: self.connection, + max_keys_per_req: self.max_keys_per_req, + max_concurrent_reqs: self.max_concurrent_reqs, + client_key, + } + } +} + +// Configuration setters live on the state-agnostic impl so they can be called +// in any order relative to `with_client_key`/`with_key_provider` — chaining a +// setter *after* the key would otherwise fail to compile. +// +// The transport knobs delegate to `HttpConnectionOpts` (which documents each +// one, including the wasm32 caveats) rather than duplicating its fields here. +impl<C, S> StackKmsBuilder<C, S> { + /// Set the **total request timeout** in seconds. Defaults to 10 seconds. + /// See [`HttpConnectionOpts::with_request_timeout`]. + pub fn with_request_timeout(mut self, timeout_secs: u64) -> Self { + self.connection = self.connection.with_request_timeout(timeout_secs); + self + } + + /// Set the **connect timeout** in seconds (TCP connect + TLS handshake only). + /// See [`HttpConnectionOpts::with_connect_timeout`]. + pub fn with_connect_timeout(mut self, timeout_secs: u64) -> Self { + self.connection = self.connection.with_connect_timeout(timeout_secs); + self + } + + /// Set the **pool idle timeout** in seconds. + /// See [`HttpConnectionOpts::with_pool_idle_timeout`]. + pub fn with_pool_idle_timeout(mut self, timeout_secs: u64) -> Self { + self.connection = self.connection.with_pool_idle_timeout(timeout_secs); + self + } + + /// Set the maximum number of keys per request. Defaults to 500. Must be at + /// least 1 (validated at [`build`](Self::build) time). + pub fn with_max_keys_per_req(mut self, max_keys: usize) -> Self { + self.max_keys_per_req = max_keys; + self + } + + /// Set the maximum number of concurrent requests. Defaults to 5. Must be at + /// least 1 (validated at [`build`](Self::build) time). + pub fn with_max_concurrent_reqs(mut self, max_concurrent: usize) -> Self { + self.max_concurrent_reqs = max_concurrent; + self + } + + /// Pin the ZeroKMS endpoint, bypassing both the environment and the + /// token's `services` claim. + /// + /// Takes an already-validated [`ZeroKmsEndpoint`] (parse one with + /// `"https://…".parse()?`), so a bad URL is rejected where it is written + /// rather than on the first request. + pub fn with_base_url(mut self, base_url: ZeroKmsEndpoint) -> Self { + self.connection = self.connection.with_base_url(base_url); + self + } + + fn build_opts(self) -> Result<(ClientOpts<HttpConnectionOpts>, C, S), StackKmsBuilderError> { + let mut connection = self.connection; + if connection.base_url().is_none() { + if let Some(endpoint) = Self::base_url_from_env()? { + connection = connection.with_base_url(endpoint); + } + } + + // `ClientOpts` rejects degenerate limits (0 keys-per-req would panic + // `slice::chunks`; 0 concurrent-reqs would leave the request stream + // pending forever) so they never reach `map_async_chunked`. + let opts = ClientOpts::new(connection) + .with_max_keys_per_req(self.max_keys_per_req)? + .with_max_concurrent_reqs(self.max_concurrent_reqs)?; + + Ok((opts, self.credentials, self.client_key)) + } + + /// Resolve the ZeroKMS endpoint from the `CS_ZEROKMS_HOST` environment + /// variable (or legacy `CS_VITUR_HOST`). The first variable that is set + /// decides: an unusable value is an error, not a fall-through. + fn base_url_from_env() -> Result<Option<ZeroKmsEndpoint>, StackKmsBuilderError> { + use crate::vars::CS_ZEROKMS_HOST; + + for env_var in CS_ZEROKMS_HOST { + if let Ok(value) = std::env::var(env_var) { + return value + .parse() + .map(Some) + .map_err(|source| StackKmsBuilderError::InvalidEndpoint { env_var, source }); + } + } + + Ok(None) + } +} + +impl<C> StackKmsBuilder<C, ClientKey> +where + C: AuthStrategyBounds, + for<'a> &'a C: AuthStrategy, +{ + /// Build a [`StackKms`] client. + pub fn build(self) -> Result<StackKms<C>, StackKmsBuilderError> { + let (opts, credentials, client_key) = self.build_opts()?; + Ok(StackKms::connect(opts, credentials, client_key)?) + } +} + +/// Newtype wrapper that marks a [`KeyProvider`] in the builder's type state. +/// +/// This avoids coherence issues — [`ClientKey`] does not implement [`KeyProvider`], +/// and `WithKeyProvider` keeps the two `build()` signatures unambiguous. +pub struct WithKeyProvider<K: KeyProvider>(K); + +impl<C, K> StackKmsBuilder<C, WithKeyProvider<K>> +where + C: AuthStrategyBounds, + for<'a> &'a C: AuthStrategy, + K: KeyProvider, +{ + /// Build a [`StackKms`] client by loading the key from the provider. + /// + /// This is an async method because the key provider may need to perform I/O. + pub async fn build(self) -> Result<StackKms<C>, StackKmsBuilderError> { + let (opts, credentials, provider) = self.build_opts()?; + let client_key = provider.0.client_key().await?; + Ok(StackKms::connect(opts, credentials, client_key)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_env::ScopedEnv; + use stack_auth::{AuthError, AuthStrategyFn, ServiceToken}; + + type NeverStrategy = + AuthStrategyFn<fn() -> std::future::Ready<Result<ServiceToken, AuthError>>>; + + fn never_get_token() -> std::future::Ready<Result<ServiceToken, AuthError>> { + unreachable!("builder tests never fetch a token") + } + + fn builder() -> StackKmsBuilder<NeverStrategy, ()> { + StackKmsBuilder::new(AuthStrategyFn::new(never_get_token as fn() -> _)) + } + + fn random_client_key() -> ClientKey { + use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + ClientKey::new_v1(uuid::Uuid::new_v4(), ProxyKeySet::generate(&ek_a, &ek_b)) + } + + mod invalid_config { + use super::*; + + #[test] + fn rejects_zero_max_keys_per_req() { + let err = builder() + .with_max_keys_per_req(0) + .with_client_key(random_client_key()) + .build() + .err() + .expect("zero keys-per-req must be rejected"); + assert!( + matches!(err, StackKmsBuilderError::InvalidConfig(_)), + "expected InvalidConfig, got: {err:?}" + ); + assert!(err.to_string().contains("max_keys_per_req"), "{err}"); + } + + #[test] + fn rejects_zero_max_concurrent_reqs() { + let err = builder() + .with_max_concurrent_reqs(0) + .with_client_key(random_client_key()) + .build() + .err() + .expect("zero concurrent-reqs must be rejected"); + assert!( + matches!(err, StackKmsBuilderError::InvalidConfig(_)), + "expected InvalidConfig, got: {err:?}" + ); + assert!(err.to_string().contains("max_concurrent_reqs"), "{err}"); + } + + #[test] + fn accepts_the_defaults() { + builder() + .with_client_key(random_client_key()) + .build() + .expect("default limits are valid"); + } + } + + mod base_url_from_env { + use super::*; + + // Pinned by name rather than read from `vars::CS_ZEROKMS_HOST` so a + // reordering of that list (which changes precedence) fails these tests. + const PRIMARY: &str = "CS_ZEROKMS_HOST"; + const LEGACY: &str = "CS_VITUR_HOST"; + + fn from_env() -> Result<Option<ZeroKmsEndpoint>, StackKmsBuilderError> { + StackKmsBuilder::<NeverStrategy>::base_url_from_env() + } + + #[test] + fn the_primary_variable_is_listed_first() { + assert_eq!(crate::vars::CS_ZEROKMS_HOST, &[PRIMARY, LEGACY]); + } + + #[test] + fn returns_none_when_neither_variable_is_set() { + let _env = ScopedEnv::new(&[(PRIMARY, None), (LEGACY, None)]); + assert!(from_env().unwrap().is_none()); + } + + #[test] + fn parses_the_primary_variable() { + let _env = ScopedEnv::new(&[ + (PRIMARY, Some("https://primary.example")), + (LEGACY, Some("https://legacy.example")), + ]); + let url = from_env().unwrap().unwrap(); + assert_eq!(url.as_str(), "https://primary.example/"); + } + + #[test] + fn falls_back_to_the_legacy_variable() { + let _env = ScopedEnv::new(&[(PRIMARY, None), (LEGACY, Some("https://legacy.example"))]); + let url = from_env().unwrap().unwrap(); + assert_eq!(url.as_str(), "https://legacy.example/"); + } + + #[test] + fn an_invalid_primary_is_an_error_even_when_the_legacy_variable_is_valid() { + let _env = ScopedEnv::new(&[ + (PRIMARY, Some("not a url")), + (LEGACY, Some("https://legacy.example")), + ]); + let err = from_env().unwrap_err(); + assert!( + matches!( + &err, + StackKmsBuilderError::InvalidEndpoint { env_var, source: InvalidEndpoint::Parse(_) } + if *env_var == PRIMARY + ), + "got: {err:?}" + ); + assert!(err.to_string().contains(PRIMARY), "{err}"); + } + + #[test] + fn a_scheme_less_host_and_port_is_rejected_naming_the_variable() { + // `Url::parse` accepts `localhost:3002` (scheme `localhost`), so + // without endpoint validation this would build and then fail every + // request with an opaque "Failed to construct request URL". + let _env = ScopedEnv::new(&[(PRIMARY, Some("localhost:3002")), (LEGACY, None)]); + let err = from_env().unwrap_err(); + assert!( + matches!( + &err, + StackKmsBuilderError::InvalidEndpoint { env_var, source: InvalidEndpoint::NoHost(_) } + if *env_var == PRIMARY + ), + "got: {err:?}" + ); + } + + #[test] + fn an_invalid_endpoint_fails_build() { + let _env = ScopedEnv::new(&[(PRIMARY, Some("localhost:3002")), (LEGACY, None)]); + let err = builder() + .with_client_key(random_client_key()) + .build() + .err() + .expect("an invalid env endpoint must fail build"); + assert!( + matches!(err, StackKmsBuilderError::InvalidEndpoint { .. }), + "got: {err:?}" + ); + } + + #[test] + fn an_explicit_endpoint_takes_precedence_and_the_env_is_not_consulted() { + let _env = ScopedEnv::new(&[(PRIMARY, Some("not a url")), (LEGACY, None)]); + builder() + .with_base_url("https://explicit.example".parse().unwrap()) + .with_client_key(random_client_key()) + .build() + .expect("an explicit endpoint must not be overridden by a bad env value"); + } + } +} diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs new file mode 100644 index 000000000..887d6f604 --- /dev/null +++ b/packages/stack-kms/src/client.rs @@ -0,0 +1,1143 @@ +use std::borrow::Cow; +use uuid::Uuid; +use zerokms_protocol::{ + GenerateKeyRequest, GenerateKeySpec, GeneratedKey, IdentifiedBy, Keyset, LoadKeysetRequest, + LoadKeysetResponse, RetrieveKeyRequest, RetrieveKeyRequestFallible, RetrieveKeySpec, + RetrievedKey, UnverifiedContext, ViturRequest, ViturRequestError, +}; + +use recipher::key::Iv; +use stack_auth::{AuthStrategy, AuthStrategyBounds}; +use vitaminc::random::{Generatable, SafeRand}; + +#[cfg(feature = "http")] +use crate::connection::HttpConnection; +use crate::connection::ZeroKMSConnection; +use crate::errors::{Error, GenerateKeyError, LoadKeysetError, RetrieveKeyError}; +use crate::futures::map_async_chunked; +use crate::key::{ClientKey, DataKey, DataKeyWithTag, IndexKey}; +use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; + +/// Default [`ClientOpts::max_keys_per_req`]. +pub const DEFAULT_KEYS_PER_REQ: usize = 500; +/// Default [`ClientOpts::max_concurrent_reqs`]. +pub const DEFAULT_CONCURRENT_REQS: usize = 5; + +/// Returned when a [`ClientOpts`] limit is set to a value the client can't +/// operate with (currently: a zero `max_keys_per_req` or `max_concurrent_reqs`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("Invalid client options: {0}")] +pub struct InvalidClientOpts(&'static str); + +/// Options for configuring certain behaviours of the [`Client`]. +/// +// The builder is the `http` feature's entry point; without it the host +// constructs `ClientOpts` for its own transport directly. +#[cfg_attr( + feature = "http", + doc = "You should generally use the [`StackKmsBuilder`](crate::StackKmsBuilder) to create a configured instance rather than instantiating this struct directly.\n" +)] +/// +/// The limits are validated by the `with_*` setters, so a `ClientOpts` value +/// is always usable: a zero `max_keys_per_req` would panic in `slice::chunks` +/// and a zero `max_concurrent_reqs` would leave the request stream pending +/// forever, so both are rejected at construction rather than at call time. +pub struct ClientOpts<CONNOPTS> { + max_keys_per_req: usize, + max_concurrent_reqs: usize, + connection_opts: CONNOPTS, +} + +impl<CONNOPTS> ClientOpts<CONNOPTS> { + /// Options with the default limits ([`DEFAULT_KEYS_PER_REQ`] keys per + /// request, [`DEFAULT_CONCURRENT_REQS`] concurrent requests) and the given + /// connection options. + pub fn new(connection_opts: CONNOPTS) -> Self { + Self { + max_keys_per_req: DEFAULT_KEYS_PER_REQ, + max_concurrent_reqs: DEFAULT_CONCURRENT_REQS, + connection_opts, + } + } + + /// The maximum number of key specs in each generate or retrieve request to + /// ZeroKMS. Too large a number can exceed reqwest's max body size. Must be + /// at least 1. + pub fn with_max_keys_per_req(mut self, max_keys: usize) -> Result<Self, InvalidClientOpts> { + if max_keys == 0 { + return Err(InvalidClientOpts("max_keys_per_req must be at least 1")); + } + self.max_keys_per_req = max_keys; + Ok(self) + } + + /// The maximum number of requests spun up per call to `generate_keys` or + /// `retrieve_keys`. Too many concurrent requests can result in dropped + /// connections which fail the calls. Must be at least 1. + pub fn with_max_concurrent_reqs( + mut self, + max_concurrent: usize, + ) -> Result<Self, InvalidClientOpts> { + if max_concurrent == 0 { + return Err(InvalidClientOpts("max_concurrent_reqs must be at least 1")); + } + self.max_concurrent_reqs = max_concurrent; + Ok(self) + } + + /// The maximum number of key specs per request. + pub fn max_keys_per_req(&self) -> usize { + self.max_keys_per_req + } + + /// The maximum number of concurrent requests per key operation. + pub fn max_concurrent_reqs(&self) -> usize { + self.max_concurrent_reqs + } + + /// The connection options used to initialize the ZeroKMS connection. + pub fn connection_opts(&self) -> &CONNOPTS { + &self.connection_opts + } +} + +/// Low-level client for ZeroKMS key generation and retrieval. +/// +/// The client is generic over the transport [`ZeroKMSConnection`]; the default +/// [`HttpConnection`] talks to a real ZeroKMS endpoint. Each method takes an +/// access token directly — see [`StackKms`] for the high-level wrapper that +/// fetches and refreshes tokens via [`stack_auth`]. +#[cfg(feature = "http")] +pub struct Client<C = HttpConnection> { + connection: C, + max_keys_per_req: usize, + max_concurrent_reqs: usize, +} + +/// Low-level client for ZeroKMS key generation and retrieval, generic over +/// the transport [`ZeroKMSConnection`]. (Without the `http` feature there is +/// no default connection: the host supplies one.) +#[cfg(not(feature = "http"))] +pub struct Client<C> { + connection: C, + max_keys_per_req: usize, + max_concurrent_reqs: usize, +} + +/// Returned by the [`Client::retrieve_keys_fallible`] method. +pub type FallibleDataKeyVec = Vec<Result<DataKey, RetrieveKeyError>>; + +impl<C> Client<C> { + /// Returns a reference to the underlying connection. + pub(crate) fn connection(&self) -> &C { + &self.connection + } +} + +impl<C: ZeroKMSConnection + Send + Sync> Client<C> { + pub fn init_opts(opts: ClientOpts<C::ConnectionOpts>) -> Result<Self, C::Error> { + let connection = C::init(opts.connection_opts)?; + + Ok(Self { + connection, + max_keys_per_req: opts.max_keys_per_req, + max_concurrent_reqs: opts.max_concurrent_reqs, + }) + } + + /// Shared scaffolding for the batch operations: split `specs` into chunks + /// of at most `max_keys_per_req`, send up to `max_concurrent_reqs` chunks + /// to ZeroKMS at once, check that every response carries exactly one entry + /// per spec, and zip the entries back onto their specs — in order — with + /// `map_key`. + /// + /// `operation` is recorded as a field on the per-chunk trace lines, so + /// operators can filter by operation (`retrieve_keys`, + /// `retrieve_keys_fallible`, `generate_keys`). It is a field rather than a + /// `tracing` target because `tracing` targets are baked into static + /// callsite metadata and so must be literals. + #[allow(clippy::too_many_arguments)] + async fn send_chunked<'a, Spec, Req, Item, Out, E>( + &self, + operation: &'static str, + specs: &'a [Spec], + access_token: &str, + make_request: impl Fn(&'a [Spec]) -> Req + Sync, + response_keys: impl Fn(Req::Response) -> Vec<Item> + Sync, + map_key: impl Fn(&'a Spec, Item) -> Result<Out, E> + Sync, + count_mismatch: impl Fn(usize, usize) -> E + Sync, + ) -> Result<Vec<Out>, E> + where + Spec: Send + Sync, + Req: ViturRequest, + E: From<ViturRequestError> + std::fmt::Display, + { + let result = map_async_chunked( + specs, + |chunk| async { + tracing::trace!(target: "stack_kms::client", operation, "sending request with {} keys", chunk.len()); + + let keys = self + .connection + .send(make_request(chunk), access_token) + .await + .map(&response_keys) + .map_err(E::from)?; + + // This should never happen with ZeroKMS but check just to be sure. + if keys.len() != chunk.len() { + return Err(count_mismatch(chunk.len(), keys.len())); + } + + tracing::trace!(target: "stack_kms::client", operation, "received {} keys - creating data keys", keys.len()); + + chunk + .iter() + .zip(keys) + .map(|(spec, item)| map_key(spec, item)) + .collect::<Result<Vec<_>, E>>() + }, + self.max_keys_per_req, + self.max_concurrent_reqs, + ) + .await; + + match &result { + Err(x) => { + tracing::trace!(target: "stack_kms::client", operation, "failed with error: {x}") + } + Ok(x) => { + tracing::trace!(target: "stack_kms::client", operation, "successfully processed {} keys", x.len()) + } + } + + result + } + + /// Retrieve multiple data keys for an iterator of [`RetrieveKeyPayload`]. + pub async fn retrieve_keys( + &self, + keys: impl IntoIterator<Item = RetrieveKeyPayload<'_>>, + key: &ClientKey, + keyset_id: Option<Uuid>, + access_token: &str, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, RetrieveKeyError> { + tracing::trace!(target: "stack_kms::retrieve_keys", "preparing payloads"); + + let keys = keys + .into_iter() + .map(RetrieveKeySpec::from) + .collect::<Vec<_>>(); + + tracing::trace!(target: "stack_kms::retrieve_keys", max_keys_per_req = self.max_keys_per_req, max_parallel_reqs = self.max_concurrent_reqs); + + self.send_chunked( + "retrieve_keys", + &keys, + access_token, + |keys| RetrieveKeyRequest { + keys: keys.into(), + keyset_id: keyset_id.map(Into::into), + client_id: key.key_id, + unverified_context: unverified_context.cloned().unwrap_or_default(), + }, + |res| res.keys, + |RetrieveKeySpec { iv, .. }, RetrievedKey { key_material }| { + DataKey::from_key_material(key, iv.into_inner(), &key_material) + .map_err(RetrieveKeyError::from) + }, + |expected, received| RetrieveKeyError::InvalidNumberOfKeys { expected, received }, + ) + .await + } + + /// Retrieve multiple data keys, returning a per-key result so partial failures + /// don't fail the whole batch. + pub async fn retrieve_keys_fallible<'a>( + &self, + keys: impl IntoIterator<Item = RetrieveKeyPayload<'_>>, + client_key: &ClientKey, + keyset_id: Option<Uuid>, + access_token: &str, + unverified_context: Option<Cow<'a, UnverifiedContext>>, + ) -> Result<FallibleDataKeyVec, RetrieveKeyError> { + tracing::trace!(target: "stack_kms::retrieve_keys_fallible", "preparing payloads"); + + let keys = keys + .into_iter() + .map(RetrieveKeySpec::from) + .collect::<Vec<_>>(); + + tracing::trace!(target: "stack_kms::retrieve_keys_fallible", max_keys_per_req = self.max_keys_per_req, max_parallel_reqs = self.max_concurrent_reqs); + + self.send_chunked( + "retrieve_keys_fallible", + &keys, + access_token, + |keys| RetrieveKeyRequestFallible { + keys: keys.into(), + keyset_id: keyset_id.map(Into::into), + client_id: client_key.key_id, + unverified_context: unverified_context.clone().unwrap_or_default(), + }, + |res| res.keys, + |RetrieveKeySpec { iv, .. }, result| { + // Both failure modes stay per-key so one bad entry doesn't + // fail the whole fallible batch: a server-side retrieval + // failure and invalid key material in an otherwise-successful + // entry. + Ok(result + .map_err(RetrieveKeyError::FailedRetrieval) + .and_then(|key| { + DataKey::from_key_material(client_key, iv.into_inner(), &key.key_material) + .map_err(RetrieveKeyError::from) + })) + }, + |expected, received| RetrieveKeyError::InvalidNumberOfKeys { expected, received }, + ) + .await + } + + /// Load a keyset and derive its [`IndexKey`] from the returned partial + /// keyset-root key material. If `keyset_id` is `None`, the client's default + /// keyset is loaded. + pub async fn load_keyset( + &self, + client_key: &ClientKey, + keyset_id: Option<IdentifiedBy>, + access_token: &str, + ) -> Result<(Keyset, IndexKey), LoadKeysetError> { + let req = LoadKeysetRequest { + client_id: client_key.key_id, + keyset_id, + }; + + let LoadKeysetResponse { + keyset, + partial_index_key, + } = self.connection.send(req, access_token).await?; + + let index_key = IndexKey::from_key_material(client_key, &partial_index_key.key_material)?; + + Ok((keyset, index_key)) + } + + /// Generate multiple data keys for an iterator of [`GenerateKeyPayload`]. + pub async fn generate_keys<'a>( + &self, + keys: impl IntoIterator<Item = GenerateKeyPayload<'_>>, + client_key: &ClientKey, + keyset_id: Option<Uuid>, + access_token: &str, + unverified_context: Option<Cow<'a, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, GenerateKeyError> { + let keys = { + // Security: use vitaminc's `SafeRand` (CSPRNG) for IV generation + // rather than `rand::thread_rng()`. + let mut rng = SafeRand::from_entropy().map_err(GenerateKeyError::GenerateIv)?; + + keys.into_iter() + .map( + |GenerateKeyPayload { + descriptor, + context, + decryption_policy, + }| { + let iv: Iv = + Generatable::random(&mut rng).map_err(GenerateKeyError::GenerateIv)?; + Ok(if let Some(policy) = decryption_policy { + GenerateKeySpec::new_with_policy(iv, descriptor, policy) + } else { + // `context` is owned by this closure and used once — move it. + GenerateKeySpec::new_with_context(iv, descriptor, context) + }) + }, + ) + .collect::<Result<Vec<_>, GenerateKeyError>>()? + }; + + tracing::trace!(target: "stack_kms::generate_keys", "generated {} key payloads", keys.len()); + tracing::trace!(target: "stack_kms::generate_keys", max_keys_per_req = self.max_keys_per_req, max_parallel_reqs = self.max_concurrent_reqs); + + self.send_chunked( + "generate_keys", + &keys, + access_token, + |keys| GenerateKeyRequest { + keys: keys.into(), + keyset_id: keyset_id.map(Into::into), + client_id: client_key.key_id, + unverified_context: unverified_context.clone().unwrap_or_default(), + }, + |res| res.keys, + |GenerateKeySpec { iv, .. }, + GeneratedKey { + key_material, + tag, + decryption_policy, + }| { + DataKeyWithTag::from_key_material( + client_key, + iv.into_inner(), + &key_material, + tag, + decryption_policy, + ) + .map_err(GenerateKeyError::from) + }, + |expected, received| GenerateKeyError::InvalidNumberOfKeys { expected, received }, + ) + .await + } +} + +/// High-level client for generating and retrieving ZeroKMS data keys. +/// +/// `StackKms` owns the transport [`Client`], a [`stack_auth`] credential +/// provider, and a [`ClientKey`]. Each operation fetches a fresh access token +/// (refreshing as needed), resolves the ZeroKMS endpoint from the token's +/// `services` claim on first use, and delegates to the low-level client. +/// +/// Build one with [`StackKmsBuilder`](crate::StackKmsBuilder) (the default +/// HTTP transport, `http` feature), or with [`connect`](Self::connect) over +/// any [`ZeroKMSConnection`]. +#[cfg(feature = "http")] +pub struct StackKms<C, Conn = HttpConnection> { + client: Client<Conn>, + credentials: C, + client_key: ClientKey, +} + +/// `StackKms` owns the transport [`Client`], a [`stack_auth`] credential +/// provider, and a [`ClientKey`]. Without the `http` feature there is no +/// default connection: build one with [`connect`](Self::connect) over the +/// host's [`ZeroKMSConnection`]. +#[cfg(not(feature = "http"))] +pub struct StackKms<C, Conn> { + client: Client<Conn>, + credentials: C, + client_key: ClientKey, +} + +impl<C, Conn> StackKms<C, Conn> +where + C: AuthStrategyBounds, + for<'a> &'a C: AuthStrategy, + Conn: ZeroKMSConnection + Send + Sync, +{ + /// Build a client over an explicit transport. + /// + /// This is the seam for hosts that provide their own transport (the + /// WASI/wazero guest implements [`ZeroKMSConnection`] over a host-imported + /// function) and the only constructor available without the `http` + /// feature. + #[cfg_attr( + feature = "http", + doc = "With it, [`StackKmsBuilder`](crate::StackKmsBuilder) is the usual way to configure the default [`HttpConnection`].\n" + )] + /// + /// The connection is initialised from `opts`'s connection options; the + /// ZeroKMS endpoint is taken from the access token's `services` claim on + /// first use unless the connection already knows one. + pub fn connect( + opts: ClientOpts<Conn::ConnectionOpts>, + credentials: C, + client_key: ClientKey, + ) -> Result<Self, Error> { + let client = Client::init_opts(opts).map_err(|e| Error::ConnectionInit(Box::new(e)))?; + Ok(Self { + client, + credentials, + client_key, + }) + } + + /// Fetch a token from the credentials provider and ensure the ZeroKMS base + /// URL has been resolved on the connection (from the token's `services` + /// claim). The URL is only resolved once; subsequent calls skip resolution. + async fn get_token(&self) -> Result<stack_auth::ServiceToken, Error> { + let token = (&self.credentials).get_token().await?; + if !self.client.connection().has_base_url() { + let endpoint = crate::endpoint::ZeroKmsEndpoint::try_from(token.zerokms_url()?)?; + tracing::debug!( + target: "stack_kms", + %endpoint, + "resolved ZeroKMS endpoint from the token's services claim" + ); + self.client.connection().ensure_base_url(endpoint); + } + Ok(token) + } + + /// The [`ClientKey`] this client uses to derive data keys. + pub fn client_key(&self) -> &ClientKey { + &self.client_key + } + + /// Generate multiple data keys for an iterator of [`GenerateKeyPayload`]. + pub async fn generate_keys<'a>( + &self, + payloads: impl IntoIterator<Item = GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'a, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, Error> { + let token = self.get_token().await?; + + self.client + .generate_keys( + payloads, + &self.client_key, + keyset_id, + token.as_str(), + unverified_context, + ) + .await + .map_err(Error::from) + } + + /// Retrieve multiple data keys for an iterator of [`RetrieveKeyPayload`]. + pub async fn retrieve_keys( + &self, + payloads: impl IntoIterator<Item = RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, Error> { + let token = self.get_token().await?; + + self.client + .retrieve_keys( + payloads, + &self.client_key, + keyset_id, + token.as_str(), + unverified_context, + ) + .await + .map_err(Error::from) + } + + /// Load a keyset and derive its [`IndexKey`] — the deterministic per-keyset + /// key used to generate index terms (Searchable Encrypted Metadata). If + /// `keyset_id` is `None`, the client's default keyset is loaded; the + /// returned [`Keyset`] carries the resolved id. + pub async fn load_keyset( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Keyset, IndexKey), Error> { + let token = self.get_token().await?; + + let (keyset, index_key) = self + .client + .load_keyset(&self.client_key, keyset_id, token.as_str()) + .await?; + + tracing::debug!(target: "stack_kms::load_keyset", "loaded keyset: [{}]({})", keyset.id, keyset.name); + + Ok((keyset, index_key)) + } + + /// Retrieve multiple data keys, returning a per-key result so partial + /// failures don't fail the whole batch. + pub async fn retrieve_keys_fallible<'a>( + &self, + payloads: impl IntoIterator<Item = RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'a, UnverifiedContext>>, + ) -> Result<FallibleDataKeyVec, Error> { + let token = self.get_token().await?; + + tracing::debug!(target: "stack_kms::retrieve_keys_fallible", "got token, retrieving keys"); + self.client + .retrieve_keys_fallible( + payloads, + &self.client_key, + keyset_id, + token.as_str(), + unverified_context, + ) + .await + .map_err(Error::from) + } +} + +#[cfg(test)] +mod test_connection; + +#[cfg(test)] +mod tests { + use super::test_connection::*; + use super::*; + use crate::key::V1KeySet; + use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + use std::borrow::Cow; + use uuid::uuid; + use zerokms_protocol::{ + GenerateKeyResponse, GeneratedKey, RetrieveKeyResponse, RetrievedKey, ViturKeyMaterial, + }; + + fn random_client_key() -> ClientKey { + let domain_key = EncryptionKeySet::generate().unwrap(); + let authority_key = EncryptionKeySet::generate().unwrap(); + let keyset = ProxyKeySet::generate(&authority_key, &domain_key); + + ClientKey { + key_id: uuid!("00000000-0000-0000-0000-000000000000"), + keyset: V1KeySet(keyset), + } + } + + fn build_client( + callback: impl FnOnce(TestConnectionBuilder) -> TestConnectionBuilder, + ) -> Client<TestConnection> { + let builder = callback(TestConnectionBuilder::new()); + let client_opts = ClientOpts::new(builder) + .with_max_keys_per_req(10) + .unwrap() + .with_max_concurrent_reqs(5) + .unwrap(); + Client::init_opts(client_opts).expect("Failed to initialize test client") + } + + // 528 bytes is the size of the key material returned by ZeroKMS for the + // recipher proxy re-encryption scheme. + fn key_material() -> ViturKeyMaterial { + ViturKeyMaterial::from(vec![7u8; 528]) + } + + fn generated_key(tag: Vec<u8>) -> GeneratedKey { + GeneratedKey { + key_material: key_material(), + tag, + decryption_policy: None, + } + } + + fn policy(claim: &str, value: &str) -> zerokms_protocol::DecryptionPolicy { + zerokms_protocol::DecryptionPolicy { + conditions: vec![zerokms_protocol::PolicyCondition { + claim: claim.to_string(), + value: Some(value.to_string()), + }], + } + } + + mod client_opts { + use super::*; + + #[test] + fn defaults_to_the_documented_limits() { + let opts = ClientOpts::new(()); + assert_eq!(opts.max_keys_per_req(), DEFAULT_KEYS_PER_REQ); + assert_eq!(opts.max_concurrent_reqs(), DEFAULT_CONCURRENT_REQS); + } + + #[test] + fn rejects_zero_max_keys_per_req() { + let err = ClientOpts::new(()) + .with_max_keys_per_req(0) + .err() + .expect("zero must be rejected"); + assert!(err.to_string().contains("max_keys_per_req"), "{err}"); + } + + #[test] + fn rejects_zero_max_concurrent_reqs() { + let err = ClientOpts::new(()) + .with_max_concurrent_reqs(0) + .err() + .expect("zero must be rejected"); + assert!(err.to_string().contains("max_concurrent_reqs"), "{err}"); + } + + #[test] + fn accepts_positive_limits() { + let opts = ClientOpts::new(()) + .with_max_keys_per_req(1) + .unwrap() + .with_max_concurrent_reqs(1) + .unwrap(); + assert_eq!(opts.max_keys_per_req(), 1); + assert_eq!(opts.max_concurrent_reqs(), 1); + } + } + + /// `StackKms` over an injected connection — the seam a host with its own + /// transport (the WASI/wazero guest) builds through, and the only + /// constructor without the `http` feature. + mod connect_over_any_connection { + use super::*; + use stack_auth::StaticTokenStrategy; + + #[tokio::test] + async fn generate_keys_round_trips_through_the_injected_connection() { + let builder = TestConnectionBuilder::new() + .add_effect::<GenerateKeyRequest, _>(|req| { + assert_eq!(req.keys.len(), 2, "both specs go to the connection"); + }) + .add_success_response::<GenerateKeyRequest>(GenerateKeyResponse { + keys: vec![generated_key(vec![1]), generated_key(vec![2])], + }); + let opts = ClientOpts::new(builder); + + let kms = StackKms::<_, TestConnection>::connect( + opts, + StaticTokenStrategy::new("static-token"), + random_client_key(), + ) + .expect("connect over a test connection"); + + let keys = kms + .generate_keys( + vec![ + GenerateKeyPayload::new("a", Cow::Owned(vec![])), + GenerateKeyPayload::new("b", Cow::Owned(vec![])), + ], + None, + None, + ) + .await + .expect("keys come back through the injected connection"); + + assert_eq!(keys.len(), 2); + assert_eq!(keys[0].tag, vec![1]); + assert_eq!(keys[1].tag, vec![2]); + } + } + + mod count_mismatch { + use super::*; + + #[tokio::test] + async fn generate_keys_rejects_a_short_response() { + let client_key = random_client_key(); + // Ask for two, stub a response with only one. + let client = build_client(|builder| { + builder.add_success_response::<GenerateKeyRequest>(GenerateKeyResponse { + keys: vec![generated_key(vec![1])], + }) + }); + + let err = client + .generate_keys( + vec![ + GenerateKeyPayload::new("a", Cow::Owned(vec![])), + GenerateKeyPayload::new("b", Cow::Owned(vec![])), + ], + &client_key, + None, + "token", + None, + ) + .await + .expect_err("count mismatch must be an error"); + + assert!( + matches!( + err, + GenerateKeyError::InvalidNumberOfKeys { + expected: 2, + received: 1 + } + ), + "expected InvalidNumberOfKeys, got: {err:?}" + ); + } + + #[tokio::test] + async fn retrieve_keys_rejects_a_short_response() { + let client_key = random_client_key(); + let client = build_client(|builder| { + builder.add_success_response::<RetrieveKeyRequest>(RetrieveKeyResponse { + keys: vec![], + }) + }); + + let err = client + .retrieve_keys( + vec![RetrieveKeyPayload::new(Iv::default(), "a", &[1])], + &client_key, + None, + "token", + None, + ) + .await + .expect_err("count mismatch must be an error"); + + assert!( + matches!( + err, + RetrieveKeyError::InvalidNumberOfKeys { + expected: 1, + received: 0 + } + ), + "expected InvalidNumberOfKeys, got: {err:?}" + ); + } + + #[tokio::test] + async fn retrieve_keys_fallible_rejects_a_short_response() { + let client_key = random_client_key(); + let client = build_client(|builder| { + builder.add_success_response::<RetrieveKeyRequestFallible>( + zerokms_protocol::RetrieveKeyResponseFallible { keys: vec![] }, + ) + }); + + let err = client + .retrieve_keys_fallible( + vec![RetrieveKeyPayload::new(Iv::default(), "a", &[1])], + &client_key, + None, + "token", + None, + ) + .await + .expect_err("count mismatch must be an error"); + + assert!( + matches!( + err, + RetrieveKeyError::InvalidNumberOfKeys { + expected: 1, + received: 0 + } + ), + "expected InvalidNumberOfKeys, got: {err:?}" + ); + } + } + + mod transport_errors { + use super::*; + use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; + + fn vitur_error(kind: ViturRequestErrorKind) -> ViturRequestError { + ViturRequestError::new(kind, "stubbed", std::io::Error::other("boom")) + } + + #[tokio::test] + async fn generate_keys_classifies_a_forbidden_response() { + let client_key = random_client_key(); + let client = build_client(|builder| { + builder.add_failed_response::<GenerateKeyRequest>(vitur_error( + ViturRequestErrorKind::Forbidden, + )) + }); + + let err = client + .generate_keys( + vec![GenerateKeyPayload::new("a", Cow::Owned(vec![]))], + &client_key, + None, + "token", + None, + ) + .await + .expect_err("a failed request must surface"); + + assert!( + matches!(err, GenerateKeyError::Forbidden), + "expected Forbidden, got: {err:?}" + ); + } + + #[tokio::test] + async fn retrieve_keys_wraps_the_transport_error() { + let client_key = random_client_key(); + let client = build_client(|builder| { + builder.add_failed_response::<RetrieveKeyRequest>(vitur_error( + ViturRequestErrorKind::SendRequest, + )) + }); + + let err = client + .retrieve_keys( + vec![RetrieveKeyPayload::new(Iv::default(), "a", &[1])], + &client_key, + None, + "token", + None, + ) + .await + .expect_err("a failed request must surface"); + + assert!( + matches!( + &err, + RetrieveKeyError::RequestFailed(e) + if matches!(e.kind, ViturRequestErrorKind::SendRequest) + ), + "expected RequestFailed(SendRequest), got: {err:?}" + ); + } + } + + #[tokio::test] + async fn generate_keys_forwards_the_decryption_policy_and_returns_the_resolved_one() { + use std::sync::{Arc, Mutex}; + + let client_key = random_client_key(); + let requested = policy("sub", "alice"); + // ZeroKMS fills in `None` claim values; simulate a resolved policy that + // differs from the request to prove the *response* policy is returned. + let resolved = policy("sub", "alice-resolved"); + + let seen: Arc<Mutex<Option<GenerateKeyRequest<'static>>>> = Arc::new(Mutex::new(None)); + let seen_in_effect = seen.clone(); + + let client = build_client(|builder| { + builder + .add_effect::<GenerateKeyRequest, _>(move |req| { + *seen_in_effect.lock().unwrap() = Some(req); + }) + .add_success_response::<GenerateKeyRequest>(GenerateKeyResponse { + keys: vec![ + GeneratedKey { + key_material: key_material(), + tag: vec![1], + decryption_policy: Some(resolved.clone()), + }, + generated_key(vec![2]), + ], + }) + }); + + let ctx = vec![zerokms_protocol::Context::Tag("dropped-with-policy".into())]; + let keys = client + .generate_keys( + vec![ + GenerateKeyPayload::new("a", Cow::Borrowed(&ctx)) + .with_decryption_policy(requested.clone()), + GenerateKeyPayload::new("b", Cow::Borrowed(&ctx)), + ], + &client_key, + None, + "token", + None, + ) + .await + .expect("generate_keys should succeed"); + + // Request side: the policy-bearing spec carries the policy and no + // context; the plain spec carries the context and no policy. + let req = seen + .lock() + .unwrap() + .take() + .expect("the effect should have captured the request"); + assert_eq!(req.keys.len(), 2); + assert_eq!(req.keys[0].decryption_policy.as_ref(), Some(&requested)); + assert!(req.keys[0].context.is_empty()); + assert!(req.keys[1].decryption_policy.is_none()); + assert_eq!(req.keys[1].context.len(), 1); + + // Response side: the resolved policy lands on the returned key. + assert_eq!(keys[0].decryption_policy.as_ref(), Some(&resolved)); + assert!(keys[1].decryption_policy.is_none()); + } + + #[tokio::test] + async fn generate_keys_returns_a_key_per_payload() { + let client_key = random_client_key(); + + let client = build_client(|builder| { + builder.add_success_response::<GenerateKeyRequest>(GenerateKeyResponse { + keys: vec![ + GeneratedKey { + key_material: key_material(), + tag: vec![1, 2, 3], + decryption_policy: None, + }, + GeneratedKey { + key_material: key_material(), + tag: vec![4, 5, 6], + decryption_policy: None, + }, + ], + }) + }); + + let payloads = vec![ + GenerateKeyPayload::new("a", Cow::Owned(vec![])), + GenerateKeyPayload::new("b", Cow::Owned(vec![])), + ]; + + let keys = client + .generate_keys(payloads, &client_key, None, "token", None) + .await + .expect("generate_keys should succeed"); + + assert_eq!(keys.len(), 2); + assert_eq!(keys[0].tag, vec![1, 2, 3]); + assert_eq!(keys[1].tag, vec![4, 5, 6]); + } + + #[tokio::test] + async fn retrieve_keys_returns_a_key_per_payload() { + let client_key = random_client_key(); + + let client = build_client(|builder| { + builder.add_success_response::<RetrieveKeyRequest>(RetrieveKeyResponse { + keys: vec![RetrievedKey { + key_material: key_material(), + }], + }) + }); + + let iv = Iv::default(); + let payloads = vec![RetrieveKeyPayload::new(iv, "a", &[1, 2, 3])]; + + let keys = client + .retrieve_keys(payloads, &client_key, None, "token", None) + .await + .expect("retrieve_keys should succeed"); + + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].iv, iv); + } + + #[tokio::test] + async fn generate_then_retrieve_derives_the_same_data_key() { + let client_key = random_client_key(); + // `ViturKeyMaterial` isn't `Clone`, so build two from the same bytes — + // ZeroKMS returns the same underlying material for generate + retrieve. + let shared_bytes = vec![7u8; 528]; + + // Generate one key. + let gen_client = build_client(|builder| { + builder.add_success_response::<GenerateKeyRequest>(GenerateKeyResponse { + keys: vec![GeneratedKey { + key_material: ViturKeyMaterial::from(shared_bytes.clone()), + tag: vec![9, 9, 9], + decryption_policy: None, + }], + }) + }); + + let generated = gen_client + .generate_keys( + vec![GenerateKeyPayload::new("desc", Cow::Owned(vec![]))], + &client_key, + None, + "token", + None, + ) + .await + .expect("generate should succeed"); + + let generated_iv = generated[0].key.iv; + + // Retrieve using the IV that was generated; ZeroKMS returns the same + // underlying key material, so the derived DataKey must match. + let ret_client = build_client(|builder| { + builder.add_success_response::<RetrieveKeyRequest>(RetrieveKeyResponse { + keys: vec![RetrievedKey { + key_material: ViturKeyMaterial::from(shared_bytes.clone()), + }], + }) + }); + + let retrieved = ret_client + .retrieve_keys( + vec![RetrieveKeyPayload::new(generated_iv, "desc", &[9, 9, 9])], + &client_key, + None, + "token", + None, + ) + .await + .expect("retrieve should succeed"); + + assert_eq!(generated[0].key.key(), retrieved[0].key()); + } + + #[tokio::test] + async fn load_keyset_derives_a_deterministic_index_key() { + let client_key = random_client_key(); + let keyset_id = uuid!("11111111-1111-1111-1111-111111111111"); + let shared_bytes = vec![7u8; 528]; + + let keyset = |material: Vec<u8>| { + build_client(|builder| { + builder.add_success_response::<LoadKeysetRequest>(LoadKeysetResponse { + partial_index_key: RetrievedKey { + key_material: ViturKeyMaterial::from(material), + }, + keyset: Keyset { + id: keyset_id, + name: "default".to_string(), + description: String::new(), + is_disabled: false, + is_default: true, + }, + }) + }) + }; + + let (loaded_a, key_a) = keyset(shared_bytes.clone()) + .load_keyset(&client_key, None, "token") + .await + .expect("load_keyset should succeed"); + let (_, key_b) = keyset(shared_bytes) + .load_keyset(&client_key, Some(keyset_id.into()), "token") + .await + .expect("load_keyset should succeed"); + + assert_eq!(loaded_a.id, keyset_id); + // Same key material derives the same index key — write-time and + // query-time terms must agree. + assert_eq!(key_a.key(), key_b.key()); + + // Different key material derives a different index key. + let (_, key_c) = keyset(vec![8u8; 528]) + .load_keyset(&client_key, None, "token") + .await + .expect("load_keyset should succeed"); + assert_ne!(key_a.key(), key_c.key()); + } + + #[tokio::test] + async fn retrieve_keys_fallible_surfaces_per_key_results() { + let client_key = random_client_key(); + + // One key succeeds, one fails: the batch call itself succeeds and the + // per-key results land in payload order. + let client = build_client(|builder| { + builder.add_success_response::<RetrieveKeyRequestFallible>( + zerokms_protocol::RetrieveKeyResponseFallible { + keys: vec![ + Ok(RetrievedKey { + key_material: key_material(), + }), + Err("key not found".to_string()), + ], + }, + ) + }); + + let iv = Iv::default(); + let keys = client + .retrieve_keys_fallible( + vec![ + RetrieveKeyPayload::new(iv, "a", &[1]), + RetrieveKeyPayload::new(iv, "b", &[2]), + ], + &client_key, + None, + "token", + None, + ) + .await + .expect("batch call itself should succeed"); + + assert_eq!(keys.len(), 2); + assert!(keys[0].is_ok()); + assert!( + matches!(&keys[1], Err(RetrieveKeyError::FailedRetrieval(msg)) if msg == "key not found"), + "a per-key failure must be surfaced as FailedRetrieval, got: {:?}", + keys[1] + ); + } +} diff --git a/packages/stack-kms/src/client/test_connection.rs b/packages/stack-kms/src/client/test_connection.rs new file mode 100644 index 000000000..17560b471 --- /dev/null +++ b/packages/stack-kms/src/client/test_connection.rs @@ -0,0 +1,131 @@ +//! In-memory [`ZeroKMSConnection`] used by unit tests to stub ZeroKMS +//! responses without touching the network. + +use async_mutex::Mutex; +use zerokms_protocol::{ViturRequest, ViturRequestError}; + +use crate::connection::{ZeroKMSConnection, ZeroKMSConnectionInit}; + +type EffectHandlers = Vec<(String, Box<dyn FnOnce(&str) + Send>)>; +type RequestHandlers = Vec<(String, Result<String, ViturRequestError>)>; + +pub(crate) struct TestConnectionBuilder { + handlers: RequestHandlers, + effects: EffectHandlers, +} + +impl TestConnectionBuilder { + pub(crate) fn new() -> Self { + Self { + handlers: vec![], + effects: vec![], + } + } + + /// Add a matcher for a particular request, returning a success message. + /// + /// The matcher is only run once. + pub(crate) fn add_success_response<R: ViturRequest>(mut self, response: R::Response) -> Self { + self.handlers.push(( + R::ENDPOINT.to_string(), + Ok(serde_json::to_string(&response) + .expect("Failed to serialise success response. This shouldn't happen.")), + )); + self + } + + /// Add a matcher for a particular request, returning a [`ViturRequestError`]. + /// + /// The matcher is only run once. + pub(crate) fn add_failed_response<R: ViturRequest>(mut self, error: ViturRequestError) -> Self { + self.handlers.push((R::ENDPOINT.to_string(), Err(error))); + self + } + + /// Add a matcher for a particular request, running an effect on the body of the request. + /// + /// This matcher is only run once. + pub(crate) fn add_effect<R: ViturRequest, H: FnOnce(R) + Send + 'static>( + mut self, + handler: H, + ) -> Self { + let endpoint = R::ENDPOINT; + + self.effects.push(( + endpoint.to_string(), + Box::new(move |message| { + handler(serde_json::from_str(message).expect( + "Failed to parse request from message in test effect. This shouldn't happen.", + )) + }), + )); + + self + } + + pub(crate) fn build(self) -> TestConnection { + TestConnection { + handlers: Mutex::new(self.handlers), + effects: Mutex::new(self.effects), + } + } +} + +impl Default for TestConnectionBuilder { + fn default() -> Self { + Self::new() + } +} + +pub(crate) struct TestConnection { + handlers: Mutex<RequestHandlers>, + effects: Mutex<EffectHandlers>, +} + +impl ZeroKMSConnectionInit for TestConnection { + type ConnectionOpts = TestConnectionBuilder; + type Error = std::convert::Infallible; + + fn init(builder: Self::ConnectionOpts) -> Result<Self, Self::Error> { + Ok(builder.build()) + } +} + +impl ZeroKMSConnection for TestConnection { + // The stub has no URL to resolve — it dispatches on the request's endpoint + // name — so the trait's defaults (no-op `ensure_base_url`, `has_base_url` + // of `true`) are exactly right here. + async fn send<Request: ViturRequest>( + &self, + request: Request, + _access_token: &str, + ) -> Result<Request::Response, ViturRequestError> { + let endpoint = Request::ENDPOINT; + + let mut effect_guard = self.effects.lock().await; + + let effect_position = effect_guard.iter().position(|(x, _)| x == endpoint); + + let body = serde_json::to_string(&request) + .expect("Failed to serialise request body in test connection"); + + if let Some(index) = effect_position { + let (_, effect) = effect_guard.remove(index); + effect(&body); + } + + let mut handler_guard = self.handlers.lock().await; + + let index = handler_guard + .iter() + .position(|(x, _)| x == endpoint) + .unwrap_or_else(|| panic!("No handler defined for request: {endpoint}")); + + let (_, body) = handler_guard.remove(index); + + body.map(|x| { + serde_json::from_str(&x) + .expect("Failed to parse response body from handler in test connection") + }) + } +} diff --git a/packages/stack-kms/src/connection.rs b/packages/stack-kms/src/connection.rs new file mode 100644 index 000000000..90e2842c8 --- /dev/null +++ b/packages/stack-kms/src/connection.rs @@ -0,0 +1,75 @@ +//! The transport seam. +//! +//! [`ZeroKMSConnection`] is how the client sends a [`ViturRequest`] and gets +//! its response back. The default implementation, [`HttpConnection`], speaks +//! HTTPS via reqwest and lives behind the `http` feature. Hosts that provide +//! their own transport — the WASI/wazero guest, where HTTP is a host import — +//! implement the trait themselves and build the client with +//! [`StackKms::connect`](crate::StackKms::connect); with the `http` feature +//! off, reqwest and its native TLS stack are not in the dependency graph at +//! all. + +use std::future::Future; + +use zerokms_protocol::{ViturRequest, ViturRequestError}; + +use crate::endpoint::ZeroKmsEndpoint; + +// Transport-independent: a guest built without the `http` feature classifies +// responses exactly as `HttpConnection` does. +mod classify; +pub use classify::{ + classify_response, is_json_content_type, BaseUrlUnresolved, FailureResponse, + UnexpectedContentType, +}; + +#[cfg(feature = "http")] +mod http; +#[cfg(feature = "http")] +pub use http::{ConnectionInitError, HttpConnection, HttpConnectionOpts}; + +pub trait ZeroKMSConnectionInit { + type ConnectionOpts; + type Error: std::error::Error + Send + Sync + 'static; + + fn init(opts: Self::ConnectionOpts) -> Result<Self, Self::Error> + where + Self: Sized; +} + +/// The returned future is bounded by [`MaybeSend`](crate::MaybeSend): `Send` +/// on native targets so callers can drive it on a multi-threaded runtime, +/// unbounded on wasm32 — reqwest's fetch-backed response futures aren't +/// `Send`, and edge runtimes are single-threaded anyway. +pub trait ZeroKMSConnection: ZeroKMSConnectionInit { + fn send<Request: ViturRequest>( + &self, + request: Request, + access_token: &str, + ) -> impl Future<Output = Result<Request::Response, ViturRequestError>> + crate::MaybeSend; + + /// Record the ZeroKMS endpoint if none is known yet. + /// + /// [`StackKms`](crate::StackKms) calls this with the endpoint named by the + /// access token's `services` claim the first time it holds a token. + /// + /// Endpoint discovery is `StackKms` policy, not something every transport + /// needs: the default is a no-op, paired with a `has_base_url` of `true`, + /// which is correct for a transport that does not build URLs from a base + /// (it dispatches on the request's endpoint name) or that was pinned at + /// init. A transport that *does* want discovery must override **both**, + /// and its `ensure_base_url` must keep the first value it is given — an + /// endpoint pinned at init must not be overridden by a later token. + fn ensure_base_url(&self, _url: ZeroKmsEndpoint) {} + + /// Whether an endpoint is known, either from init or from a previous + /// [`ensure_base_url`](Self::ensure_base_url). While this is `false`, + /// [`send`](Self::send) cannot build a request URL. + /// + /// Defaults to `true`: a transport that needs no base URL always has + /// everything it needs, so `StackKms` never tries to resolve one from a + /// token. Override alongside [`ensure_base_url`](Self::ensure_base_url). + fn has_base_url(&self) -> bool { + true + } +} diff --git a/packages/stack-kms/src/connection/classify.rs b/packages/stack-kms/src/connection/classify.rs new file mode 100644 index 000000000..5f141c942 --- /dev/null +++ b/packages/stack-kms/src/connection/classify.rs @@ -0,0 +1,254 @@ +//! Response classification, shared by every [`ZeroKMSConnection`] regardless +//! of transport. +//! +//! Whether the bytes arrived over reqwest ([`HttpConnection`]) or over a wasm +//! host import (the WASI guest's connection), a ZeroKMS response is classified +//! the same way: 2xx must be JSON and deserialize, and 404/401/403/409 carry +//! specific [`ViturRequestErrorKind`]s so callers can tell a bad token from a +//! missing keyset without parsing strings. That table lives here, once, free +//! of any feature gate — a guest built without the `http` feature still gets +//! the same verdicts as the default transport. +//! +//! [`ZeroKMSConnection`]: crate::ZeroKMSConnection +//! [`HttpConnection`]: crate::HttpConnection + +use std::collections::HashMap; + +use serde::de::DeserializeOwned; +use serde_json::from_slice; +use thiserror::Error; +use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; + +/// No ZeroKMS base URL is known: none was configured, and none was resolved +/// from the access token's `services` claim. +/// +/// Classified as a request-*preparation* error, never an authentication +/// failure — a caller that read it as a 401 would refresh its token and retry +/// forever against what is really a configuration problem. +#[derive(Debug, Error)] +#[error("ZeroKMS base URL was not resolved from the token's `services` claim")] +pub struct BaseUrlUnresolved; + +/// A 2xx response whose `Content-Type` is not JSON — typically a proxy or load +/// balancer answering with an HTML error page. +/// +/// `Display` carries only what was received and expected: the body and headers +/// are attacker-influenced and unbounded, and this type's `Display` reaches +/// logs. They stay available through `Debug` for structured inspection. +#[derive(Debug, Error)] +#[error("Received '{received:?}', expected '{expected}'")] +#[non_exhaustive] +pub struct UnexpectedContentType { + pub received: Option<String>, + pub expected: &'static str, + pub body: Option<String>, + pub headers: HashMap<String, String>, +} + +/// A non-2xx ZeroKMS response. +/// +/// `Display` is the status alone, for the same reason as +/// [`UnexpectedContentType`]: body and headers are unbounded server text that +/// must not be pulled into a log line. `Debug` still carries them. +#[derive(Debug, Error)] +#[error("Status: {status}")] +#[non_exhaustive] +pub struct FailureResponse { + pub status: u16, + pub body: Option<String>, + pub headers: HashMap<String, String>, +} + +/// `true` if a `content-type` header value denotes JSON, ignoring any +/// parameters (`application/json; charset=utf-8`) and ASCII case — proxies and +/// API gateways commonly normalise the header that way. +pub fn is_json_content_type(value: &str) -> bool { + value + .split(';') + .next() + .map(str::trim) + .is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json")) +} + +/// Classify one ZeroKMS response. +/// +/// `status` is the HTTP status code, `content_type` the response +/// `Content-Type` if the transport could read one, `body` the response body +/// (`None` when the transport read it and failed), and `headers` whatever the +/// transport can cheaply supply for the error payloads — an empty map is fine +/// for transports that do not surface them. +/// +/// The error bodies captured into [`FailureResponse`] / +/// [`UnexpectedContentType`] are non-2xx (or non-JSON) server error text, not +/// key material: the only payload that carries wrapped keys is a 2xx JSON +/// body, which is consumed by deserialization here and wiped by the caller. +pub fn classify_response<T: DeserializeOwned>( + status: u16, + content_type: Option<&str>, + body: Option<&[u8]>, + headers: HashMap<String, String>, +) -> Result<T, ViturRequestError> { + let text = || body.map(|b| String::from_utf8_lossy(b).into_owned()); + + if (200..=299).contains(&status) { + let expected = "application/json"; + if !content_type.is_some_and(is_json_content_type) { + return Err(ViturRequestError::parse( + "Invalid content type header", + UnexpectedContentType { + received: content_type.map(|ct| ct.to_owned()), + expected, + body: text(), + headers, + }, + )); + } + let body = body.ok_or_else(|| { + ViturRequestError::parse( + "Failed to deserialize response body", + FailureResponse { + status, + body: None, + headers: headers.clone(), + }, + ) + })?; + return from_slice(body) + .map_err(|e| ViturRequestError::parse("Failed to deserialize response body", e)); + } + + let failure = FailureResponse { + status, + body: text(), + headers, + }; + Err(match status { + 404 => ViturRequestError::new( + ViturRequestErrorKind::NotFound, + "Resource not found", + failure, + ), + 401 => ViturRequestError::new( + ViturRequestErrorKind::Unauthorized, + "Request unauthorized", + failure, + ), + 403 => ViturRequestError::new( + ViturRequestErrorKind::Forbidden, + "Request forbidden", + failure, + ), + 409 => ViturRequestError::new( + ViturRequestErrorKind::Conflict, + "Resource conflict", + failure, + ), + _ => ViturRequestError::other("Server returned failure response", failure), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use zerokms_protocol::Keyset; + + const KEYSETS_JSON: &[u8] = br#"[ + {"id":"6a70bd18-99ac-4650-b104-37eec3a15b09","name":"alpha","description":"","is_disabled":false,"is_default":true} + ]"#; + + fn classify( + status: u16, + content_type: Option<&str>, + body: Option<&[u8]>, + ) -> Result<Vec<Keyset>, ViturRequestError> { + classify_response(status, content_type, body, HashMap::new()) + } + + fn kind_of(result: Result<Vec<Keyset>, ViturRequestError>) -> ViturRequestErrorKind { + result.expect_err("expected an error").kind + } + + #[test] + fn accepts_json_with_or_without_parameters_and_ignoring_case() { + for value in [ + "application/json", + "application/json; charset=utf-8", + "application/json;charset=UTF-8", + " Application/JSON ; charset=utf-8", + ] { + assert!(is_json_content_type(value), "{value:?} should be accepted"); + } + } + + #[test] + fn rejects_other_media_types() { + for value in [ + "text/html", + "application/jsonx", + "text/json", + "", + "; charset=utf-8", + ] { + assert!(!is_json_content_type(value), "{value:?} should be rejected"); + } + } + + #[test] + fn success_with_json_content_type_deserializes() { + let keysets = + classify(200, Some("application/json"), Some(KEYSETS_JSON)).expect("deserializes"); + assert_eq!(keysets.len(), 1); + assert_eq!(keysets[0].name, "alpha"); + + let keysets = classify( + 200, + Some("Application/JSON; charset=utf-8"), + Some(KEYSETS_JSON), + ) + .expect("deserializes"); + assert_eq!(keysets.len(), 1); + } + + #[test] + fn success_without_json_content_type_is_a_parse_error() { + assert!(matches!( + kind_of(classify(200, None, Some(KEYSETS_JSON))), + ViturRequestErrorKind::ParseResponse + )); + // A proxy or load balancer answering 200 with an HTML error page. + assert!(matches!( + kind_of(classify( + 200, + Some("text/html"), + Some(b"<html>gateway error</html>") + )), + ViturRequestErrorKind::ParseResponse + )); + // A 2xx whose body could not be read at all. + assert!(matches!( + kind_of(classify(200, Some("application/json"), None)), + ViturRequestErrorKind::ParseResponse + )); + assert!(matches!( + kind_of(classify(200, Some("application/json"), Some(b"not json"))), + ViturRequestErrorKind::ParseResponse + )); + } + + #[test] + fn error_statuses_map_to_their_kinds() { + for (status, expected) in [ + (401, ViturRequestErrorKind::Unauthorized), + (403, ViturRequestErrorKind::Forbidden), + (404, ViturRequestErrorKind::NotFound), + (409, ViturRequestErrorKind::Conflict), + (500, ViturRequestErrorKind::Other), + ] { + assert_eq!( + std::mem::discriminant(&kind_of(classify(status, None, Some(b"nope")))), + std::mem::discriminant(&expected), + "status {status}" + ); + } + } +} diff --git a/packages/stack-kms/src/connection/http.rs b/packages/stack-kms/src/connection/http.rs new file mode 100644 index 000000000..e8f693607 --- /dev/null +++ b/packages/stack-kms/src/connection/http.rs @@ -0,0 +1,316 @@ +//! [`HttpConnection`]: the default [`ZeroKMSConnection`], speaking HTTPS to a +//! ZeroKMS endpoint via reqwest. Behind the `http` feature so that hosts which +//! provide their own transport (the WASI/wazero guest) can build the crate +//! without reqwest — and its native TLS stack — in the graph at all. + +use super::classify::{classify_response, is_json_content_type, BaseUrlUnresolved}; +use super::{ZeroKMSConnection, ZeroKMSConnectionInit}; +use crate::endpoint::ZeroKmsEndpoint; +use crate::user_agent::get_user_agent; +use reqwest::header::HeaderMap; +use serde_json::to_vec; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +use std::{collections::HashMap, sync::OnceLock}; +use thiserror::Error; +use zerokms_protocol::{ViturRequest, ViturRequestError}; + +#[cfg(not(target_arch = "wasm32"))] +const REQUEST_TIMEOUT_SECS: u64 = 10; + +#[derive(Debug, Error)] +#[error("Failed to initialize HTTP connection: {0}")] +pub struct ConnectionInitError(#[from] reqwest::Error); + +pub struct HttpConnectionOpts { + base_url: Option<ZeroKmsEndpoint>, + request_timeout: Option<u64>, + connect_timeout: Option<u64>, + pool_idle_timeout: Option<u64>, +} + +impl HttpConnectionOpts { + /// Options for a connection to `base_url`, or — when `None` — to whatever + /// endpoint the access token's `services` claim names (resolved on first + /// use via [`HttpConnection::ensure_base_url`]). + pub fn new(base_url: Option<ZeroKmsEndpoint>) -> Self { + Self { + base_url, + request_timeout: None, + connect_timeout: None, + pool_idle_timeout: None, + } + } + + /// Pin the endpoint, replacing any earlier value. + pub fn with_base_url(mut self, base_url: ZeroKmsEndpoint) -> Self { + self.base_url = Some(base_url); + self + } + + pub(crate) fn base_url(&self) -> Option<&ZeroKmsEndpoint> { + self.base_url.as_ref() + } + + /// Set the **total request timeout** in seconds — covers connect + TLS + /// handshake + body send + body receive together. If not set, defaults + /// to 10 seconds. + /// + /// Set this larger when calling endpoints whose server-side processing + /// time scales with payload size (e.g. `generate-data-key` with large + /// `keys.len()`), or when running over high-latency / variable-quality + /// networks. See [`with_connect_timeout`](Self::with_connect_timeout) + /// to bound the connect phase separately. + /// + /// Ignored on wasm32 — reqwest's fetch-backed `ClientBuilder` doesn't + /// expose `.timeout()` and the host runtime (e.g. Supabase Edge, Cloudflare + /// Workers) owns request lifetime there. + pub fn with_request_timeout(mut self, timeout_secs: u64) -> Self { + self.request_timeout = Some(timeout_secs); + self + } + + /// Set the **connect timeout** in seconds — bound on TCP connect + TLS + /// handshake only, separate from the total request timeout. If not set, + /// reqwest falls back to the OS-level connect timeout (~75 s on most + /// platforms), so the only ceiling on a stuck connect is whatever the + /// total request timeout is. + /// + /// Useful for fast-fail behaviour on broken networks: a value like 5 + /// seconds gives the connect phase plenty of room without forcing the + /// total request timeout to absorb both connect *and* response time. + /// + /// Ignored on wasm32 for the same reason as + /// [`with_request_timeout`](Self::with_request_timeout): the host + /// runtime owns connection lifetime under fetch. + pub fn with_connect_timeout(mut self, timeout_secs: u64) -> Self { + self.connect_timeout = Some(timeout_secs); + self + } + + /// Set the **pool idle timeout** in seconds — how long the underlying + /// reqwest client keeps an idle keep-alive connection in its pool + /// before closing it. If not set, reqwest's default of 90 s applies. + /// + /// Long-lived processes (bulk ingest, daemons) benefit from raising + /// this so warm TLS connections survive idle gaps between batches. + /// + /// Ignored on wasm32 — connection pooling is owned by the host + /// runtime under fetch. + pub fn with_pool_idle_timeout(mut self, timeout_secs: u64) -> Self { + self.pool_idle_timeout = Some(timeout_secs); + self + } +} + +pub struct HttpConnection { + base_url: OnceLock<ZeroKmsEndpoint>, + client: reqwest::Client, +} + +fn header_map_to_hash(map: &HeaderMap) -> HashMap<String, String> { + map.iter() + .filter_map(|(k, v)| { + v.to_str() + .map(|x| x.to_string()) + .ok() + .map(|v| (k.to_string(), v)) + }) + .collect() +} + +impl ZeroKMSConnectionInit for HttpConnection { + type ConnectionOpts = HttpConnectionOpts; + type Error = ConnectionInitError; + + fn init(opts: Self::ConnectionOpts) -> Result<Self, Self::Error> { + let builder = reqwest::ClientBuilder::new().user_agent(get_user_agent()); + // wasm32 reqwest uses `fetch` and doesn't expose `.timeout()`, + // `.connect_timeout()` or `.pool_idle_timeout()` — the host runtime + // owns request lifetime and connection pooling. The corresponding + // `with_*` builder methods are documented as no-ops on wasm32. + #[cfg(not(target_arch = "wasm32"))] + let builder = { + let mut b = builder.timeout(Duration::from_secs( + opts.request_timeout.unwrap_or(REQUEST_TIMEOUT_SECS), + )); + if let Some(connect_timeout) = opts.connect_timeout { + b = b.connect_timeout(Duration::from_secs(connect_timeout)); + } + if let Some(pool_idle_timeout) = opts.pool_idle_timeout { + b = b.pool_idle_timeout(Duration::from_secs(pool_idle_timeout)); + } + b + }; + #[cfg(target_arch = "wasm32")] + let _ = ( + opts.request_timeout, + opts.connect_timeout, + opts.pool_idle_timeout, + ); + + let client = builder.build()?; + + let base_url = OnceLock::new(); + if let Some(url) = opts.base_url { + // Pre-fill when an explicit URL was provided at build time. + let _ = base_url.set(url); + } + + Ok(Self { base_url, client }) + } +} + +impl ZeroKMSConnection for HttpConnection { + fn ensure_base_url(&self, url: ZeroKmsEndpoint) { + // OnceLock::set returns Err if already set — that's fine, we keep the first value. + let _ = self.base_url.set(url); + } + + fn has_base_url(&self) -> bool { + self.base_url.get().is_some() + } + + async fn send<Request: ViturRequest>( + &self, + request: Request, + access_token: &str, + ) -> Result<Request::Response, ViturRequestError> { + let body = to_vec(&request) + .map_err(|e| ViturRequestError::prepare("Failed to serialize request", e))?; + + let base_url = self.base_url.get().ok_or_else(|| { + // A missing base URL is a client-side configuration problem (the + // token carried no ZeroKMS `services` claim and none was set + // explicitly), not an authentication failure — classify it as a + // request-preparation error so callers don't mistake it for a 401 + // and trigger a token refresh/reauth loop. + ViturRequestError::prepare( + "ZeroKMS base URL was not resolved from the token's services claim", + BaseUrlUnresolved, + ) + })?; + + let url = base_url.request_url(Request::ENDPOINT); + + let response = self + .client + .post(url.as_str()) + .body(body) + .header("content-type", "application/json") + .bearer_auth(access_token) + .send() + .await + .map_err(|e| ViturRequestError::send("Failed to send request", e))?; + + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|x| x.to_str().ok()) + .map(str::to_owned); + let headers = header_map_to_hash(response.headers()); + + // Only the one path that actually needs the bytes — a 2xx already + // known to be JSON — reports an unreadable body as its own error; + // every other path classifies with whatever it could read, exactly as + // the pre-shared-classifier code did. + let deserializing = + status.is_success() && content_type.as_deref().is_some_and(is_json_content_type); + let body = response.bytes().await; + let body = if deserializing { + Some(body.map_err(|e| { + ViturRequestError::parse("Failed to read response body as bytes", e) + })?) + } else { + body.ok() + }; + + classify_response( + status.as_u16(), + content_type.as_deref(), + body.as_deref(), + headers, + ) + } +} + +#[cfg(test)] +mod base_url_tests { + use super::*; + + fn conn(base_url: Option<ZeroKmsEndpoint>) -> HttpConnection { + HttpConnection::init(HttpConnectionOpts::new(base_url)).unwrap() + } + + fn endpoint(s: &str) -> ZeroKmsEndpoint { + s.parse().unwrap() + } + + #[test] + fn is_unset_until_ensured() { + let c = conn(None); + assert!(!c.has_base_url()); + + c.ensure_base_url(endpoint("https://a.example")); + + assert!(c.has_base_url()); + assert_eq!(c.base_url.get().unwrap().as_str(), "https://a.example/"); + } + + #[test] + fn the_first_ensured_url_wins() { + let c = conn(None); + c.ensure_base_url(endpoint("https://first.example")); + c.ensure_base_url(endpoint("https://second.example")); + + assert_eq!(c.base_url.get().unwrap().as_str(), "https://first.example/"); + } + + #[test] + fn a_url_given_at_init_is_kept_over_a_later_ensure() { + let c = conn(Some(endpoint("https://init.example"))); + assert!(c.has_base_url()); + + c.ensure_base_url(endpoint("https://other.example")); + + assert_eq!(c.base_url.get().unwrap().as_str(), "https://init.example/"); + } + + #[test] + fn requests_append_the_endpoint_to_a_path_prefix() { + // URL normalisation itself is covered in `endpoint::tests`; this pins + // that the connection builds request URLs through the endpoint type + // rather than re-joining (which would drop a path prefix). + let c = conn(Some(endpoint("https://gateway.example/zerokms"))); + + assert_eq!( + c.base_url + .get() + .unwrap() + .request_url(zerokms_protocol::RetrieveKeyRequest::ENDPOINT) + .as_str(), + "https://gateway.example/zerokms/retrieve-data-key" + ); + } + + #[tokio::test] + async fn send_without_a_base_url_is_a_prepare_error_not_an_auth_error() { + use zerokms_protocol::{GenerateKeyRequest, ViturRequestErrorKind}; + + let c = conn(None); + let req = GenerateKeyRequest { + client_id: uuid::Uuid::nil(), + keyset_id: None, + keys: std::borrow::Cow::Owned(vec![]), + unverified_context: Default::default(), + }; + + let err = c.send(req, "token").await.unwrap_err(); + + assert!( + matches!(err.kind, ViturRequestErrorKind::PrepareRequest), + "a missing base URL must not look like a 401 (and trigger a reauth loop), got: {err:?}" + ); + } +} diff --git a/packages/stack-kms/src/endpoint.rs b/packages/stack-kms/src/endpoint.rs new file mode 100644 index 000000000..6654e2aaf --- /dev/null +++ b/packages/stack-kms/src/endpoint.rs @@ -0,0 +1,272 @@ +use std::fmt; +use std::str::FromStr; + +use thiserror::Error; +use url::Url; + +/// Why a URL was rejected as a [`ZeroKmsEndpoint`]. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum InvalidEndpoint { + /// The value did not parse as a URL at all. + #[error("not a valid URL: {0}")] + Parse(#[from] url::ParseError), + + /// The URL parsed but has no authority — `localhost:8080` parses as scheme + /// `localhost`, path `8080` — so no request path could ever be joined to it. + #[error("`{0}` has no host; is the `http://` or `https://` prefix missing?")] + NoHost(String), + + /// Only `http` and `https` can reach ZeroKMS. + #[error("unsupported scheme `{0}`; expected `http` or `https`")] + Scheme(String), + + /// Request URLs are built by joining an endpoint path onto the base, which + /// discards any query or fragment — so accepting one would silently drop it. + #[error("query strings and fragments are not supported on a ZeroKMS endpoint: `{0}`")] + QueryOrFragment(String), + + /// Credentials belong in the bearer token, never in the URL. + #[error("userinfo is not supported on a ZeroKMS endpoint")] + Userinfo, +} + +/// A validated ZeroKMS base URL. +/// +/// Construction is the one place URL hygiene happens, so everything downstream +/// (the HTTP connection, the builder, the token's `services` claim) works with a +/// value that is already known to be usable: +/// +/// * scheme is `http` or `https` and the URL has a host +/// * no userinfo, query or fragment (they would be dropped or leak) +/// * the path ends in `/`, so [`request_url`](Self::request_url) *appends* +/// an endpoint path (`https://gw.example/zerokms` + `retrieve-data-key` → +/// `https://gw.example/zerokms/retrieve-data-key`) instead of replacing the +/// last segment as `Url::join` would +/// +/// ``` +/// use stack_kms::ZeroKmsEndpoint; +/// +/// let endpoint: ZeroKmsEndpoint = "https://gateway.example/zerokms".parse()?; +/// assert_eq!(endpoint.as_str(), "https://gateway.example/zerokms/"); +/// assert_eq!( +/// endpoint.request_url("retrieve-data-key").as_str(), +/// "https://gateway.example/zerokms/retrieve-data-key" +/// ); +/// +/// assert!("localhost:3002".parse::<ZeroKmsEndpoint>().is_err()); +/// # Ok::<(), stack_kms::InvalidEndpoint>(()) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ZeroKmsEndpoint(Url); + +impl ZeroKmsEndpoint { + /// Validate and normalise a parsed [`Url`]. + pub fn new(mut url: Url) -> Result<Self, InvalidEndpoint> { + if url.cannot_be_a_base() || url.host_str().is_none() { + return Err(InvalidEndpoint::NoHost(url.into())); + } + if !matches!(url.scheme(), "http" | "https") { + return Err(InvalidEndpoint::Scheme(url.scheme().to_string())); + } + if url.query().is_some() || url.fragment().is_some() { + return Err(InvalidEndpoint::QueryOrFragment(url.into())); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(InvalidEndpoint::Userinfo); + } + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + Ok(Self(url)) + } + + /// The URL for a request path (a `ViturRequest::ENDPOINT`), appended to + /// the base. Infallible: the base is known to be http(s) with a + /// slash-terminated path, and the endpoint paths are relative constants. + pub fn request_url(&self, endpoint: &str) -> Url { + let mut url = self.0.clone(); + let path = format!("{}{}", self.0.path(), endpoint); + url.set_path(&path); + url + } + + /// The normalised base URL as a string (always slash-terminated). + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Borrow the underlying [`Url`]. + pub fn as_url(&self) -> &Url { + &self.0 + } +} + +impl TryFrom<Url> for ZeroKmsEndpoint { + type Error = InvalidEndpoint; + + fn try_from(url: Url) -> Result<Self, Self::Error> { + Self::new(url) + } +} + +impl FromStr for ZeroKmsEndpoint { + type Err = InvalidEndpoint; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + Self::new(Url::parse(s)?) + } +} + +impl fmt::Display for ZeroKmsEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0.as_str()) + } +} + +impl From<ZeroKmsEndpoint> for Url { + fn from(endpoint: ZeroKmsEndpoint) -> Self { + endpoint.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn endpoint(s: &str) -> ZeroKmsEndpoint { + s.parse().unwrap_or_else(|e| panic!("{s}: {e}")) + } + + mod accepts { + use super::*; + + #[test] + fn a_bare_host() { + assert_eq!(endpoint("https://a.example").as_str(), "https://a.example/"); + } + + #[test] + fn a_host_with_port_over_http() { + assert_eq!( + endpoint("http://localhost:3002").as_str(), + "http://localhost:3002/" + ); + } + + #[test] + fn a_path_prefix_and_terminates_it_with_a_slash() { + assert_eq!( + endpoint("https://gateway.example/zerokms").as_str(), + "https://gateway.example/zerokms/" + ); + } + + #[test] + fn an_already_slash_terminated_url_unchanged() { + for s in [ + "https://a.example/", + "https://a.example/zerokms/", + "http://localhost:3002/", + ] { + assert_eq!(endpoint(s).as_str(), s, "{s} should be left as-is"); + } + } + } + + mod request_url { + use super::*; + + #[test] + fn appends_to_a_path_prefix() { + assert_eq!( + endpoint("https://gateway.example/zerokms") + .request_url("retrieve-data-key") + .as_str(), + "https://gateway.example/zerokms/retrieve-data-key" + ); + } + + #[test] + fn appends_to_a_bare_host() { + assert_eq!( + endpoint("https://a.example") + .request_url("generate-data-key") + .as_str(), + "https://a.example/generate-data-key" + ); + } + + #[test] + fn matches_url_join_for_every_protocol_endpoint() { + // `Url::join` is what the connection used before this type existed; + // the infallible `set_path` construction must agree with it. + let base = endpoint("https://gateway.example/zerokms/v1"); + for path in [ + "generate-data-key", + "retrieve-data-key", + "retrieve-data-key-fallible", + ] { + assert_eq!( + base.request_url(path), + base.as_url().join(path).unwrap(), + "{path}" + ); + } + } + } + + mod rejects { + use super::*; + + #[test] + fn a_scheme_less_host_and_port() { + // `Url::parse` accepts this — scheme `localhost`, opaque path + // `8080` — but nothing could ever be joined onto it. + assert!(matches!( + "localhost:8080".parse::<ZeroKmsEndpoint>(), + Err(InvalidEndpoint::NoHost(_)) + )); + } + + #[test] + fn a_non_http_scheme() { + assert!(matches!( + "ftp://a.example".parse::<ZeroKmsEndpoint>(), + Err(InvalidEndpoint::Scheme(s)) if s == "ftp" + )); + } + + #[test] + fn a_query_string() { + assert!(matches!( + "https://gw.example/zerokms?apikey=abc".parse::<ZeroKmsEndpoint>(), + Err(InvalidEndpoint::QueryOrFragment(_)) + )); + } + + #[test] + fn a_fragment() { + assert!(matches!( + "https://gw.example/zerokms#frag".parse::<ZeroKmsEndpoint>(), + Err(InvalidEndpoint::QueryOrFragment(_)) + )); + } + + #[test] + fn userinfo() { + assert!(matches!( + "https://user:pw@gw.example".parse::<ZeroKmsEndpoint>(), + Err(InvalidEndpoint::Userinfo) + )); + } + + #[test] + fn garbage() { + assert!(matches!( + "not a url".parse::<ZeroKmsEndpoint>(), + Err(InvalidEndpoint::Parse(_)) + )); + } + } +} diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs new file mode 100644 index 000000000..a736ecfa5 --- /dev/null +++ b/packages/stack-kms/src/errors.rs @@ -0,0 +1,304 @@ +use miette::Diagnostic; +use thiserror::Error; +use vitaminc::random::RandomError; +use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; + +/// Key material returned by ZeroKMS failed up-front validation before key +/// derivation — e.g. a truncated or corrupt response whose material is not the +/// exact length the keyset's block permutation covers. The material is +/// network-supplied, so this must surface as an error, never a panic. +#[derive(Diagnostic, Error, Debug)] +#[error("Invalid keyset key material: {0}")] +pub struct InvalidKeyMaterialError(#[from] pub recipher::errors::RecipherError); + +#[derive(Diagnostic, Error, Debug)] +pub enum RetrieveKeyError { + #[error("Failed to send request: {0}")] + RequestFailed(#[from] ViturRequestError), + #[error("Received an invalid number of keys from request. Expected {expected} but received {received}")] + InvalidNumberOfKeys { expected: usize, received: usize }, + + /// Represents an error that occurs when a single key retrieval fails. + /// May be part of a batch retrieval operation. + #[error("Failed to retrieve key: {0}")] + FailedRetrieval(String), + + #[error(transparent)] + #[diagnostic(transparent)] + InvalidKeyMaterial(#[from] InvalidKeyMaterialError), +} + +#[derive(Diagnostic, Error, Debug)] +pub enum GenerateKeyError { + #[error("Request not authorized")] + Unauthorized, + #[error("Request forbidden due to insufficient permissions")] + Forbidden, + #[error("Failed to generate IV: {0}")] + GenerateIv(RandomError), + #[error("Received an invalid number of keys from request. Expected {expected} but received {received}")] + InvalidNumberOfKeys { expected: usize, received: usize }, + + #[error(transparent)] + #[diagnostic(transparent)] + InvalidKeyMaterial(#[from] InvalidKeyMaterialError), + // Catch-all for any `ViturRequestError` not classified as Forbidden / + // Unauthorized above. Display surfaces the `kind` (operational enum + // — `SendRequest`, `Other`, `ParseResponse`, ...) and `message` + // (`&'static str`, build-time only, no dynamic data) so the bare + // failure mode is visible. The dynamic `error: ShareableError` field + // is reachable through `Error::source()` via `#[source]`, so callers + // using anyhow chain formatting (`{:?}` / `{:#}`) or `tracing` get the + // underlying transport / response error; the Display string itself + // stays free of dynamic data. + #[error("Unexpected error ({}: {})", .0.kind, .0.message)] + RequestFailed(#[source] ViturRequestError), +} + +impl From<ViturRequestError> for GenerateKeyError { + fn from(err: ViturRequestError) -> Self { + match err.kind { + ViturRequestErrorKind::Forbidden => Self::Forbidden, + ViturRequestErrorKind::Unauthorized => Self::Unauthorized, + _ => Self::RequestFailed(err), + } + } +} + +#[derive(Diagnostic, Error, Debug)] +pub enum LoadKeysetError { + // `Unauthorized` / `Forbidden` carry the underlying request error (unlike + // `GenerateKeyError`'s unit variants) because `load-keyset` has 403 + // responses that mean different things: the server rejects a *disabled* + // keyset with a 403 whose body says "Keyset disabled: ...". Display stays + // static (no dynamic data); the distinguishing server response is + // reachable through `source()`. + #[error("Request not authorized")] + Unauthorized(#[source] ViturRequestError), + #[error("Request forbidden due to insufficient permissions")] + Forbidden(#[source] ViturRequestError), + // `load-keyset` uniquely takes a caller-supplied keyset id or name, so a + // server 404 is an expected, user-actionable outcome — e.g. a typo'd + // name — not an "unexpected error". Note the server also responds 404 + // when the *client* is unknown or has no default keyset, so a 404 does + // not prove the named keyset is missing: inspect `source()` for the + // server's response body before treating this as "create the keyset". + #[error("Keyset not found (or the client is unknown or has no default keyset)")] + KeysetNotFound(#[source] ViturRequestError), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidKeyMaterial(#[from] InvalidKeyMaterialError), + // Same shape as `GenerateKeyError::RequestFailed`: Display carries only the + // static kind/message; the dynamic error stays behind `source()`. + #[error("Unexpected error ({}: {})", .0.kind, .0.message)] + RequestFailed(#[source] ViturRequestError), +} + +impl From<ViturRequestError> for LoadKeysetError { + fn from(err: ViturRequestError) -> Self { + match err.kind { + ViturRequestErrorKind::Forbidden => Self::Forbidden(err), + ViturRequestErrorKind::Unauthorized => Self::Unauthorized(err), + ViturRequestErrorKind::NotFound => Self::KeysetNotFound(err), + _ => Self::RequestFailed(err), + } + } +} + +/// Shared scaffolding for the `From<ViturRequestError>` mapping tests below: +/// one place for the fixture error and the assertions both mappings need, so +/// a new error type doesn't copy another 80 lines. +#[cfg(test)] +mod vitur_error_mapping_support { + use super::*; + + pub(super) const SOURCE_DETAIL: &str = "transport-detail-7f3a"; + + pub(super) fn err(kind: ViturRequestErrorKind) -> ViturRequestError { + ViturRequestError::new(kind, "boom", std::io::Error::other(SOURCE_DETAIL)) + } + + /// Every kind in `kinds` must map to the catch-all RequestFailed variant + /// carrying the same kind (checked via `is_request_failed_with_kind`). + pub(super) fn assert_kinds_map_to_request_failed<E: std::fmt::Debug>( + kinds: impl IntoIterator<Item = ViturRequestErrorKind>, + from: impl Fn(ViturRequestError) -> E, + is_request_failed_with_kind: impl Fn(&E, &str) -> bool, + ) { + for kind in kinds { + // `ViturRequestErrorKind` has no `PartialEq`; compare by Debug name. + let name = format!("{kind:?}"); + let mapped = from(err(kind)); + assert!( + is_request_failed_with_kind(&mapped, &name), + "{name} must map to RequestFailed carrying the same kind, got: {mapped:?}" + ); + } + } + + /// The catch-all's Display must name the kind and static message while + /// keeping the dynamic source out; the source stays reachable through the + /// error chain. + pub(super) fn assert_request_failed_display(mapped: &impl std::error::Error) { + let shown = mapped.to_string(); + assert!(shown.contains("SendRequest"), "{shown}"); + assert!(shown.contains("boom"), "{shown}"); + assert!( + !shown.contains(SOURCE_DETAIL), + "the dynamic source error must stay out of Display: {shown}" + ); + assert!( + mapped.source().is_some(), + "the source must still be reachable through the error chain" + ); + } +} + +#[cfg(test)] +mod generate_key_error_from_vitur_request_error { + use super::vitur_error_mapping_support::*; + use super::*; + + #[test] + fn forbidden_maps_to_forbidden() { + assert!(matches!( + GenerateKeyError::from(err(ViturRequestErrorKind::Forbidden)), + GenerateKeyError::Forbidden + )); + } + + #[test] + fn unauthorized_maps_to_unauthorized() { + assert!(matches!( + GenerateKeyError::from(err(ViturRequestErrorKind::Unauthorized)), + GenerateKeyError::Unauthorized + )); + } + + #[test] + fn every_other_kind_maps_to_request_failed_keeping_the_kind() { + assert_kinds_map_to_request_failed( + [ + ViturRequestErrorKind::PrepareRequest, + ViturRequestErrorKind::SendRequest, + ViturRequestErrorKind::NotFound, + ViturRequestErrorKind::Conflict, + ViturRequestErrorKind::FailureResponse, + ViturRequestErrorKind::ParseResponse, + ViturRequestErrorKind::Other, + ], + GenerateKeyError::from, + |mapped, name| matches!(mapped, GenerateKeyError::RequestFailed(e) if format!("{:?}", e.kind) == name), + ); + } + + #[test] + fn request_failed_display_names_the_kind_and_message_but_not_the_source() { + assert_request_failed_display(&GenerateKeyError::from(err( + ViturRequestErrorKind::SendRequest, + ))); + } +} + +#[cfg(test)] +mod load_keyset_error_from_vitur_request_error { + use super::vitur_error_mapping_support::*; + use super::*; + + #[test] + fn forbidden_maps_to_forbidden_keeping_the_source() { + let mapped = LoadKeysetError::from(err(ViturRequestErrorKind::Forbidden)); + assert!(matches!(mapped, LoadKeysetError::Forbidden(_))); + assert!( + std::error::Error::source(&mapped).is_some(), + "the server response (e.g. 'Keyset disabled') must stay reachable" + ); + } + + #[test] + fn unauthorized_maps_to_unauthorized_keeping_the_source() { + let mapped = LoadKeysetError::from(err(ViturRequestErrorKind::Unauthorized)); + assert!(matches!(mapped, LoadKeysetError::Unauthorized(_))); + assert!(std::error::Error::source(&mapped).is_some()); + } + + #[test] + fn not_found_maps_to_keyset_not_found() { + let mapped = LoadKeysetError::from(err(ViturRequestErrorKind::NotFound)); + assert!(matches!(mapped, LoadKeysetError::KeysetNotFound(_))); + assert!(std::error::Error::source(&mapped).is_some()); + } + + #[test] + fn every_other_kind_maps_to_request_failed_keeping_the_kind() { + assert_kinds_map_to_request_failed( + [ + ViturRequestErrorKind::PrepareRequest, + ViturRequestErrorKind::SendRequest, + ViturRequestErrorKind::Conflict, + ViturRequestErrorKind::FailureResponse, + ViturRequestErrorKind::ParseResponse, + ViturRequestErrorKind::Other, + ], + LoadKeysetError::from, + |mapped, name| matches!(mapped, LoadKeysetError::RequestFailed(e) if format!("{:?}", e.kind) == name), + ); + } + + #[test] + fn display_never_leaks_the_dynamic_source() { + for kind in [ + ViturRequestErrorKind::Forbidden, + ViturRequestErrorKind::Unauthorized, + ViturRequestErrorKind::NotFound, + ViturRequestErrorKind::SendRequest, + ] { + let mapped = LoadKeysetError::from(err(kind)); + let shown = mapped.to_string(); + assert!( + !shown.contains(SOURCE_DETAIL), + "the dynamic source error must stay out of Display: {shown}" + ); + } + } + + #[test] + fn request_failed_display_names_the_kind_and_message() { + assert_request_failed_display(&LoadKeysetError::from(err( + ViturRequestErrorKind::SendRequest, + ))); + } +} + +/// Top-level error for high-level [`StackKms`](crate::StackKms) key operations. +#[derive(Error, Debug, Diagnostic)] +pub enum Error { + #[error(transparent)] + #[diagnostic(transparent)] + GenerateKey(#[from] GenerateKeyError), + + #[error(transparent)] + #[diagnostic(transparent)] + RetrieveKey(#[from] RetrieveKeyError), + + #[error(transparent)] + #[diagnostic(transparent)] + LoadKeyset(#[from] LoadKeysetError), + + #[error(transparent)] + #[diagnostic(transparent)] + Auth(#[from] stack_auth::AuthError), + + /// The [`ZeroKMSConnection`](crate::ZeroKMSConnection) failed to + /// initialise. Boxed because the error type belongs to whichever + /// connection the client was built over. + #[error("Failed to initialize the ZeroKMS connection")] + ConnectionInit(#[source] Box<dyn std::error::Error + Send + Sync + 'static>), + + /// The ZeroKMS endpoint named by the token's `services` claim is unusable. + #[error("Invalid ZeroKMS endpoint in the token's services claim: {0}")] + InvalidEndpoint(#[from] crate::endpoint::InvalidEndpoint), + + #[error("Unexpected error: {0}")] + Unexpected(String), +} diff --git a/packages/stack-kms/src/futures.rs b/packages/stack-kms/src/futures.rs new file mode 100644 index 000000000..7ecc111ac --- /dev/null +++ b/packages/stack-kms/src/futures.rs @@ -0,0 +1,93 @@ +use futures::StreamExt; +use std::future::Future; + +/** + * Chunk an input slice and run an async callback on each of the chunks. + * The futures generated by that callback are run concurrently with their results returned in a + * vector. + */ +pub(crate) async fn map_async_chunked< + 'a, + T: Send + Sync, + U, + E, + F: Future<Output = Result<Vec<U>, E>>, + C: Send + Sync + FnMut(&'a [T]) -> F, +>( + input: &'a [T], + callback: C, + chunk_size: usize, + concurrent_futs: usize, +) -> Result<Vec<U>, E> { + let mut output = Vec::with_capacity(input.len()); + + let mut stream = futures::stream::iter(input.chunks(chunk_size).map(callback)) + .boxed() + .buffered(concurrent_futs); + + while let Some(result) = stream.next().await { + output.append(&mut result?); + } + + Ok(output) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[tokio::test] + async fn test_keeps_the_same_order() { + let input = vec![200, 100, 50, 25, 20, 15, 10, 5, 4, 3, 2, 1]; + + let output = map_async_chunked( + &input, + |x| async { + // Sleep for the chunk's own (descending) value so that a + // regression to unordered buffering would reorder the output. + tokio::time::sleep(Duration::from_millis(x[0])).await; + Result::<_, ()>::Ok(x.to_vec()) + }, + 2, + 10, + ) + .await + .unwrap(); + + assert_eq!(input, output); + } + + #[tokio::test] + async fn test_a_failing_chunk_fails_the_whole_call() { + let input = vec![1, 2, 3, 4, 5, 6]; + + let result = map_async_chunked( + &input, + |chunk| async move { + if chunk.contains(&4) { + Err(format!("chunk {chunk:?} failed")) + } else { + Ok(chunk.to_vec()) + } + }, + 2, + 3, + ) + .await; + + assert_eq!(result, Err("chunk [3, 4] failed".to_string())); + } + + #[tokio::test] + async fn test_works_when_chunks_dont_divide_nicely() { + let input = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + let output = map_async_chunked(&input, |x| async { Result::<_, ()>::Ok(x.to_vec()) }, 3, 1) + .await + .unwrap(); + + assert_eq!(input, output); + } +} diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs new file mode 100644 index 000000000..c919ba9a1 --- /dev/null +++ b/packages/stack-kms/src/key.rs @@ -0,0 +1,547 @@ +pub(crate) use recipher::{ + cipher::ProxyCipher, + key::{Iv, Key}, + keyset::ProxyKeySet as KeySet, +}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use sha2::{Digest, Sha256}; +use std::ops::Deref; +use uuid::Uuid; +use vitaminc::protected::{OpaqueDebug, TimingSafeEq}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; +use zerokms_protocol::{DecryptionPolicy, ViturKeyMaterial}; + +use crate::errors::{InvalidKeyMaterialError, LoadKeysetError}; + +/// NOTE: Debug is safe to implement because [KeySet] is opaque. +#[derive(Debug, Deserialize, Clone, Zeroize, ZeroizeOnDrop, Serialize)] +pub struct ClientKey { + #[zeroize(skip)] + #[serde(rename = "client_id")] + pub key_id: Uuid, + + #[serde(rename = "client_key")] + pub keyset: V1KeySet, +} + +impl ClientKey { + pub fn new_v1(key_id: Uuid, keyset: KeySet) -> Self { + Self { + key_id, + keyset: V1KeySet(keyset), + } + } + + pub fn to_hex_v1(&self) -> serde_cbor::Result<String> { + self.keyset.to_hex() + } + + pub fn from_bytes(key_id: Uuid, bytes: &[u8]) -> serde_cbor::Result<Self> { + Ok(Self { + key_id, + keyset: KeySet::from_bytes(bytes).map(V1KeySet)?, + }) + } + + pub fn from_hex_v1(key_id: Uuid, hex: &str) -> serde_cbor::Result<Self> { + Ok(Self { + key_id, + keyset: V1KeySet::from_hex(hex)?, + }) + } + + /// Build a v1 client key from encoded material in whichever form the + /// caller happens to hold it: **lowercase or mixed-case hex** (the + /// historical `CS_CLIENT_KEY` format, and what [`to_hex_v1`] emits) **or + /// standard padded base64** (what `secretkey.json` serialises). + /// + /// [`from_hex_v1`] is the strict lowercase-hex decoder; this is the + /// lenient one, matching [`SecretKey::from_hex`] and + /// `EnvKeyProvider`. Front-ends that take key material from an + /// untyped boundary — an environment variable, a config file, the WASI + /// guest's FFI config object — should use this, so a user pasting the + /// value out of `secretkey.json` is not rejected for the encoding. + /// + /// Decoding is constant-time (`base16ct` / `base64ct`); the intermediate + /// bytes are wiped before returning either way. + /// + /// [`to_hex_v1`]: ClientKey::to_hex_v1 + /// [`from_hex_v1`]: ClientKey::from_hex_v1 + /// [`SecretKey::from_hex`]: crate::SecretKey::from_hex + pub fn from_encoded_v1(key_id: Uuid, encoded: &str) -> serde_cbor::Result<Self> { + let mut bytes = crate::secret_key::decode_client_key_material(encoded).map_err(|e| { + <serde_cbor::Error as serde::de::Error>::custom(format!("invalid encoding: {e}")) + })?; + let result = Self::from_bytes(key_id, &bytes); + bytes.zeroize(); + result + } +} + +// FIXME: This shouldn't be Clone but it is needed right now for the JSONB indexer. +// `key` is secret DEK material, so equality is constant-time: the `TimingSafeEq` +// derive provides `ts_eq` *and* `PartialEq`/`Eq` implemented on top of it, so +// `==` is safe here — never add a variable-time `derive(PartialEq)`. +#[derive(TimingSafeEq, Zeroize, ZeroizeOnDrop, Clone)] +#[cfg_attr(test, derive(Default))] +pub struct DataKey { + pub iv: Iv, + pub key: Key, +} +opaque_debug::implement!(DataKey); + +impl DataKey { + /// Create a DataKey for a specific [`ClientKey`] given a specific initialisation vector + /// (IV) and key material obtained from ZeroKMS. + /// + /// Returns [`InvalidKeyMaterialError`] when the material is not the exact + /// length the keyset accepts (recipher validates up front) — the material + /// is network-supplied, so a truncated or corrupt response must not panic. + pub fn from_key_material( + key: &ClientKey, + iv: Iv, + key_material: &ViturKeyMaterial, + ) -> Result<Self, InvalidKeyMaterialError> { + let cipher = ProxyCipher::new(key.keyset.keyset()); + // `rect` is reencrypted key material — the derived data key is a hash of + // it — so wipe the returned copy on drop; recipher wipes its own + // intermediate block buffer. (The Sha256 block buffer keeps the final + // <=64-byte block; sha2 0.10 doesn't implement Zeroize and changing the + // hash would alter the derived key, so that residue is accepted.) + let rect = Zeroizing::new(cipher.reencrypt::<16>(&iv, key_material)?); + + let mut hasher = Sha256::new(); + hasher.update(rect.as_slice()); + + Ok(DataKey { + iv, + key: hasher.finalize().into(), + }) + } + + pub fn key(&self) -> &Key { + &self.key + } +} + +// FIXME: Making this Cloneable for now so that we can use the same key many times for the JSONB indexer. +// We should modify the indexer so each value has a separate key. +// No `PartialEq`/`Eq` on the wrapper: the `tag` is public but `key` is secret. +// If key equality is ever needed, compare `a.key == b.key` (constant-time via +// `DataKey`'s `TimingSafeEq`-derived `PartialEq`) or `a.key.ts_eq(&b.key)`. +#[derive(Clone)] +#[cfg_attr(test, derive(Default))] +pub struct DataKeyWithTag { + pub key: DataKey, + pub tag: Vec<u8>, + pub decryption_policy: Option<DecryptionPolicy>, +} +opaque_debug::implement!(DataKeyWithTag); + +impl DataKeyWithTag { + /// Create a DataKey for a specific [`ClientKey`] given a specific IV, key material and tag + /// obtained from ZeroKMS. See [`DataKey::from_key_material`] for the + /// key-material validation this inherits. + pub fn from_key_material( + key: &ClientKey, + iv: Iv, + key_material: &ViturKeyMaterial, + tag: Vec<u8>, + decryption_policy: Option<DecryptionPolicy>, + ) -> Result<Self, InvalidKeyMaterialError> { + Ok(Self { + key: DataKey::from_key_material(key, iv, key_material)?, + tag, + decryption_policy, + }) + } +} + +impl Deref for DataKeyWithTag { + type Target = DataKey; + + fn deref(&self) -> &Self::Target { + &self.key + } +} + +/// Key used specifically for generating index terms (Searchable Encrypted +/// Metadata) with PRFs and similar constructions. +/// +/// Derived from the *keyset root* key material returned by ZeroKMS's +/// `load-keyset` operation: unlike data keys, the same keyset always yields the +/// same index key, so terms generated at write time match terms generated at +/// query time. +#[derive(Zeroize, ZeroizeOnDrop, OpaqueDebug)] +pub struct IndexKey(Key); + +impl IndexKey { + /// Derive the index key for a specific [`ClientKey`] from the partial + /// keyset-root key material obtained from ZeroKMS. + /// + /// Returns [`LoadKeysetError::InvalidKeyMaterial`] when the material is + /// not the exact length the keyset accepts (33 16-byte blocks; recipher + /// owns the fact and validates up front) — the material is + /// network-supplied, so a truncated or corrupt response must not panic. + pub fn from_key_material( + key: &ClientKey, + key_material: &ViturKeyMaterial, + ) -> Result<Self, LoadKeysetError> { + // We use all zeros for the IV for the keyset index key. + // This key is not used for encryption but for indexing using PRFs and + // similar constructions. Even then, because all other data keys are + // generated using random IVs, the likelihood of collision is negligible. + let iv = Iv::default(); + let cipher = ProxyCipher::new(key.keyset.keyset()); + // `rect` is reencrypted key material — the derived index key is a hash + // of it — so wipe the returned copy on drop; recipher wipes its own + // intermediate block buffer (matches `DataKey::from_key_material`). + let rect = Zeroizing::new( + cipher + .reencrypt::<16>(&iv, key_material) + .map_err(InvalidKeyMaterialError::from)?, + ); + + let mut hasher = blake3::Hasher::new(); + // Bind the `OutputReader` so it can be wiped: it holds the final + // chaining value from which the whole XOF stream — the index key — + // is recomputable, and blake3's `zeroize` feature implements + // `Zeroize` for it but not wipe-on-drop. + let mut reader = hasher + // Fixed info string + .update(b"ZEROKMS-INDEXKEY") + .update(rect.as_slice()) + .finalize_xof(); + + let key: Key = { + let mut key = Key::default(); + reader.fill(&mut key); + key + }; + + reader.zeroize(); + hasher.zeroize(); + + Ok(Self(key)) + } + + pub fn key(&self) -> &Key { + &self.0 + } +} + +/// Test-support only: mint an [`IndexKey`] from raw bytes, bypassing the +/// keyset-root derivation. Kept off the public API so production callers can +/// only obtain an index key through +/// [`from_key_material`](IndexKey::from_key_material) (or a +/// [`IndexKeySource`](crate::IndexKeySource)) — an index key that never went +/// through `load_keyset` would silently generate index terms that match +/// nothing written by other services. +#[cfg(feature = "test-support")] +impl From<Key> for IndexKey { + fn from(key: Key) -> Self { + Self(key) + } +} + +#[derive(Debug, Clone, Zeroize, ZeroizeOnDrop)] +pub struct V1KeySet(pub(crate) KeySet); + +impl V1KeySet { + pub fn from_bytes(bytes: &[u8]) -> serde_cbor::Result<Self> { + KeySet::from_bytes(bytes).map(Self) + } + + pub(crate) fn to_hex(&self) -> serde_cbor::Result<String> { + self.0.to_bytes().map(|mut bytes| { + let hex = base16ct::lower::encode_string(&bytes); + bytes.zeroize(); + hex + }) + } + + pub(crate) fn from_hex(hex: &str) -> serde_cbor::Result<Self> { + let mut bytes = base16ct::lower::decode_vec(hex).map_err(|e| { + <serde_cbor::Error as serde::de::Error>::custom(format!("invalid hex: {e}")) + })?; + let result = Self::from_bytes(&bytes); + bytes.zeroize(); + result + } + + pub(crate) fn keyset(&self) -> &KeySet { + &self.0 + } +} + +impl Serialize for V1KeySet { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + let mut bytes = self.0.to_bytes().map_err(serde::ser::Error::custom)?; + // Zeroize the intermediate plaintext keyset buffer, matching `to_hex`, + // `from_hex` and `deserialize`. Serdect encoding is constant-time. + let result = serdect::slice::serialize_hex_lower_or_bin(&bytes, serializer); + bytes.zeroize(); + result + } +} + +impl<'de> Deserialize<'de> for V1KeySet { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + // CBOR encoded keyset is 168 bytes. `Zeroizing` wipes the buffer on + // every exit path — including the `?` early returns below, where a + // malformed or truncated input would otherwise leave whatever was + // decoded so far on the stack. + let mut buffer = Zeroizing::new([0u8; 168]); + // Discards the returned `&[u8]`: it just borrows `buffer`, which we + // read through the `Zeroizing` guard below so it still gets wiped. + let _ = serdect::array::deserialize_hex_or_bin(&mut *buffer, deserializer)?; + let keyset = KeySet::from_bytes(&*buffer).map_err(serde::de::Error::custom)?; + + Ok(Self(keyset)) + } +} + +#[cfg(test)] +mod tests { + use super::{ClientKey, DataKey}; + use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + + fn random_keyset() -> ProxyKeySet { + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + ProxyKeySet::generate(&ek_a, &ek_b) + } + + mod from_hex_v1 { + use super::*; + + #[test] + fn round_trips_through_to_hex_v1() { + let id = uuid::Uuid::new_v4(); + let hex = ClientKey::new_v1(id, random_keyset()).to_hex_v1().unwrap(); + + let restored = ClientKey::from_hex_v1(id, &hex).unwrap(); + + assert_eq!(restored.key_id, id); + assert_eq!(restored.to_hex_v1().unwrap(), hex, "hex must round-trip"); + } + + #[test] + fn rejects_non_hex_with_the_custom_message() { + let err = ClientKey::from_hex_v1(uuid::Uuid::nil(), "not hex!!").unwrap_err(); + + assert!( + err.to_string().contains("invalid hex"), + "expected the custom invalid-hex message, got: {err}" + ); + } + + #[test] + fn rejects_hex_that_is_not_a_keyset() { + let err = ClientKey::from_hex_v1(uuid::Uuid::nil(), "deadbeef").unwrap_err(); + + assert!( + !err.to_string().contains("invalid hex"), + "valid hex of the wrong shape must fail at keyset decoding, got: {err}" + ); + } + } + + mod from_encoded_v1 { + use super::*; + use base64ct::Encoding; + + /// The three encodings a user can plausibly be holding: what + /// `to_hex_v1` emits, the same value shouted, and what + /// `secretkey.json` serialises. + #[test] + fn accepts_lowercase_hex_uppercase_hex_and_base64() { + let id = uuid::Uuid::new_v4(); + let key = ClientKey::new_v1(id, random_keyset()); + let hex = key.to_hex_v1().unwrap(); + let bytes = base16ct::lower::decode_vec(&hex).unwrap(); + let base64 = base64ct::Base64::encode_string(&bytes); + + for (label, encoded) in [ + ("lowercase hex", hex.clone()), + ("uppercase hex", hex.to_uppercase()), + ("base64", base64), + ] { + let restored = ClientKey::from_encoded_v1(id, &encoded) + .unwrap_or_else(|e| panic!("{label} must decode: {e}")); + assert_eq!(restored.key_id, id, "{label}"); + assert_eq!( + restored.to_hex_v1().unwrap(), + hex, + "{label} must recover the same keyset" + ); + } + } + + #[test] + fn rejects_material_that_is_neither_hex_nor_base64() { + let err = ClientKey::from_encoded_v1(uuid::Uuid::nil(), "not hex or base64 !!") + .expect_err("must reject"); + assert!( + err.to_string().contains("invalid encoding"), + "expected the encoding message, got: {err}" + ); + } + + #[test] + fn rejects_well_encoded_material_that_is_not_a_keyset() { + let err = + ClientKey::from_encoded_v1(uuid::Uuid::nil(), "deadbeef").expect_err("must reject"); + assert!( + !err.to_string().contains("invalid encoding"), + "well-encoded bytes of the wrong shape must fail at keyset decoding, got: {err}" + ); + } + } + + mod v1_keyset_deserialize { + use super::super::V1KeySet; + + #[test] + fn rejects_a_truncated_keyset() { + // Valid hex, but shorter than the 168-byte CBOR keyset — exercises + // the early-return after the buffer was partially written. + let short = serde_json::to_string(&"00".repeat(20)).unwrap(); + let err = serde_json::from_str::<V1KeySet>(&short).unwrap_err(); + assert!(!err.to_string().is_empty()); + } + + #[test] + fn rejects_non_hex_input() { + let err = serde_json::from_str::<V1KeySet>("\"zz\"").unwrap_err(); + assert!(!err.to_string().is_empty()); + } + } + + mod index_key { + use super::*; + use crate::errors::LoadKeysetError; + use crate::key::IndexKey; + use zerokms_protocol::testing::index_key_kat; + + fn kat_client_key() -> ClientKey { + ClientKey::from_hex_v1(uuid::Uuid::nil(), index_key_kat::KEYSET_HEX).unwrap() + } + + fn kat_material() -> zerokms_protocol::ViturKeyMaterial { + index_key_kat::key_material().into() + } + + /// Known-answer test pinning the index-key derivation to the shared + /// fixture in `zerokms_protocol::testing::index_key_kat`. + /// + /// cipherstash-client (`zerokms::vitur_client::key`) runs the same KAT + /// against the same fixture: the `ZEROKMS-INDEXKEY` zero-IV blake3-XOF + /// derivation is duplicated across the two crates and must stay + /// bit-identical, or records indexed via one stack become silently + /// unfindable when queried via the other. If this test breaks, the + /// derivation changed — do NOT update the fixture without changing + /// cipherstash-client in lockstep. + #[test] + fn from_key_material_matches_the_known_answer() { + let index_key = + IndexKey::from_key_material(&kat_client_key(), &kat_material()).unwrap(); + + assert_eq!( + base16ct::lower::encode_string(index_key.key()), + index_key_kat::EXPECTED_INDEX_KEY_HEX, + ); + } + + #[test] + fn from_key_material_is_deterministic() { + let a = IndexKey::from_key_material(&kat_client_key(), &kat_material()).unwrap(); + let b = IndexKey::from_key_material(&kat_client_key(), &kat_material()).unwrap(); + assert_eq!(a.key(), b.key()); + } + + #[test] + fn from_key_material_rejects_invalid_lengths_instead_of_panicking() { + use recipher::errors::RecipherError; + + let ck = kat_client_key(); + let expected_len = index_key_kat::key_material().len(); + // Truncated, empty, non-block-multiple and over-long payloads: all + // network-supplied shapes that previously panicked inside recipher. + for len in [0usize, 1, 16, 527, 529, expected_len * 2] { + let material: zerokms_protocol::ViturKeyMaterial = vec![0u8; len].into(); + match IndexKey::from_key_material(&ck, &material) { + Err(LoadKeysetError::InvalidKeyMaterial(e)) => { + assert!( + matches!( + e.0, + RecipherError::InvalidInputLength { expected, received } + if expected == expected_len && received == len + ), + "unexpected inner error: {e:?}" + ); + } + other => panic!( + "length {len} must be rejected as InvalidKeyMaterial, got: {other:?}" + ), + } + } + } + } + + #[test] + fn test_opaque_debug_datakey() { + let key = DataKey { + iv: [0; 16], + key: [0; 32], + }; + assert_eq!(format!("{key:?}"), "DataKey { ... }"); + } + + #[test] + fn test_v1_keyset_serde() { + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + let keyset = ProxyKeySet::generate(&ek_a, &ek_b); + let v1_keyset = super::V1KeySet(keyset); + + let serialized = serde_json::to_string(&v1_keyset).unwrap(); + let deserialized: super::V1KeySet = serde_json::from_str(&serialized).unwrap(); + + // The current key implementation doesn't implement PartialEq because it can't do it safely. + assert_eq!( + v1_keyset.0.to_bytes().unwrap(), + deserialized.0.to_bytes().unwrap() + ); + } + + #[test] + fn test_client_key_toml() { + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + let keyset = ProxyKeySet::generate(&ek_a, &ek_b); + let key_id = uuid::Uuid::new_v4(); + let client_key = ClientKey::new_v1(key_id, keyset); + + let toml = toml::to_string(&client_key).unwrap(); + + let mut table = toml::Table::new(); + table.insert( + String::from("client_id"), + toml::Value::String(key_id.to_string()), + ); + table.insert( + String::from("client_key"), + toml::Value::String(client_key.to_hex_v1().unwrap()), + ); + + assert_eq!(toml, table.to_string()); + } +} diff --git a/packages/stack-kms/src/key_provider.rs b/packages/stack-kms/src/key_provider.rs new file mode 100644 index 000000000..c64f2a664 --- /dev/null +++ b/packages/stack-kms/src/key_provider.rs @@ -0,0 +1,548 @@ +//! Trait and implementations for loading a [`ClientKey`] from various sources. +//! +//! A [`KeyProvider`] is the single required input when building a key client that needs +//! to generate or retrieve data keys. Each provider yields a complete [`ClientKey`] +//! (client ID + key material) from a self-contained source. +//! +//! # Built-in providers +//! +//! | Provider | Source | +//! |----------|--------| +//! | [`EnvKeyProvider`] | `CS_CLIENT_ID` + `CS_CLIENT_KEY` environment variables | +//! | [`StaticKeyProvider`] | Wraps a [`ClientKey`] directly | +//! | [`FallbackKeyProvider`] | Tries a primary provider, falls back on [`KeyProviderError::NotConfigured`] | +//! +//! # Example +//! +//! A common pattern is to check for explicit env vars, derive an `Option<SecretKey>`, +//! and fall back to [`EnvKeyProvider`] when they are absent: +//! +//! ```no_run +//! use stack_kms::{ +//! SecretKey, FallbackKeyProvider, EnvKeyProvider, KeyProvider, +//! }; +//! +//! # async fn example() { +//! let explicit_key = SecretKey::from_env().expect("invalid key material in env"); +//! +//! // If `explicit_key` is None the fallback provider kicks in. +//! let provider = FallbackKeyProvider::new(explicit_key, EnvKeyProvider); +//! let client_key = provider.client_key().await.unwrap(); +//! # } +//! ``` + +use crate::vars::{CS_CLIENT_ID, CS_CLIENT_KEY}; +use std::future::Future; +use thiserror::Error; +use uuid::Uuid; +use zeroize::Zeroize; + +use crate::key::ClientKey; +use crate::secret_key::decode_client_key_material; + +/// Errors that can occur when loading a [`ClientKey`] from a [`KeyProvider`]. +#[derive(Debug, Error)] +pub enum KeyProviderError { + /// The provider has no key configured (e.g. env vars not set). + /// + /// [`FallbackKeyProvider`] uses this variant to decide whether to try the next provider. + #[error("Client key not configured: {0}")] + NotConfigured(String), + + /// Key material was found but is invalid (e.g. bad hex encoding). + #[error("Invalid client key: {0}")] + InvalidKey(String), + + /// An I/O or other runtime error prevented loading the key. + #[error("Failed to load client key: {0}")] + LoadError(String), +} + +/// A source of [`ClientKey`] credentials for ZeroKMS. +/// +/// Implementations must be `Send + Sync + 'static` so they can be stored in the builder +/// and used across async contexts. +/// +/// # Example +/// +/// ``` +/// use stack_kms::{KeyProvider, KeyProviderError, ClientKey, StaticKeyProvider}; +/// use uuid::Uuid; +/// +/// # async fn example() -> Result<(), KeyProviderError> { +/// let client_key = ClientKey::from_hex_v1( +/// Uuid::nil(), +/// // ... hex-encoded key material +/// # "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +/// ).unwrap(); +/// +/// let provider = StaticKeyProvider::new(client_key); +/// let key = provider.client_key().await?; +/// # Ok(()) +/// # } +/// ``` +pub trait KeyProvider: Send + Sync + 'static { + /// Load a [`ClientKey`] from this provider. + fn client_key(&self) -> impl Future<Output = Result<ClientKey, KeyProviderError>> + Send; +} + +/// Loads a [`ClientKey`] from `CS_CLIENT_ID` and `CS_CLIENT_KEY` environment variables. +/// +/// Returns [`KeyProviderError::NotConfigured`] if either variable is unset, +/// or [`KeyProviderError::InvalidKey`] if the values cannot be parsed. +/// +/// # Example +/// +/// ```no_run +/// use stack_kms::{EnvKeyProvider, KeyProvider}; +/// +/// # async fn example() { +/// let provider = EnvKeyProvider; +/// let key = provider.client_key().await.expect("env vars must be set"); +/// # } +/// ``` +pub struct EnvKeyProvider; + +impl EnvKeyProvider { + /// Parse `CS_CLIENT_ID` and `CS_CLIENT_KEY`. `CS_CLIENT_KEY` is decoded leniently: + /// either hex (the historical format) or standard padded base64 (the form that + /// `secretkey.json` writes to disk) is accepted. + fn parse(client_id: &str, client_key: &str) -> Result<ClientKey, KeyProviderError> { + let uuid = Uuid::parse_str(client_id) + .map_err(|e| KeyProviderError::InvalidKey(format!("invalid {CS_CLIENT_ID}: {e}")))?; + + let mut bytes = decode_client_key_material(client_key) + .map_err(|e| KeyProviderError::InvalidKey(format!("invalid {CS_CLIENT_KEY}: {e}")))?; + + let result = ClientKey::from_bytes(uuid, &bytes) + .map_err(|e| KeyProviderError::InvalidKey(format!("invalid {CS_CLIENT_KEY}: {e}"))); + bytes.zeroize(); + result + } +} + +impl KeyProvider for EnvKeyProvider { + async fn client_key(&self) -> Result<ClientKey, KeyProviderError> { + let client_id = std::env::var(CS_CLIENT_ID).map_err(|_| { + KeyProviderError::NotConfigured(format!("{CS_CLIENT_ID} environment variable not set")) + })?; + + let mut client_key = std::env::var(CS_CLIENT_KEY).map_err(|_| { + KeyProviderError::NotConfigured(format!("{CS_CLIENT_KEY} environment variable not set")) + })?; + + tracing::debug!("loading client key from environment variables"); + let result = Self::parse(&client_id, &client_key); + client_key.zeroize(); + result + } +} + +/// Wraps an existing [`ClientKey`] as a [`KeyProvider`]. +/// +/// Useful for tests or when the key is already available programmatically. +/// +/// # Example +/// +/// ``` +/// use stack_kms::{StaticKeyProvider, KeyProvider, ClientKey}; +/// use uuid::Uuid; +/// +/// # async fn example() { +/// # let client_key = ClientKey::from_hex_v1( +/// # Uuid::nil(), +/// # "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +/// # ).unwrap(); +/// let provider = StaticKeyProvider::new(client_key); +/// let key = provider.client_key().await.unwrap(); +/// # } +/// ``` +pub struct StaticKeyProvider(ClientKey); + +impl StaticKeyProvider { + /// Create a new [`StaticKeyProvider`] wrapping the given key. + pub fn new(key: ClientKey) -> Self { + Self(key) + } +} + +impl KeyProvider for StaticKeyProvider { + async fn client_key(&self) -> Result<ClientKey, KeyProviderError> { + Ok(self.0.clone()) + } +} + +/// Wraps an `Option<T>` as a [`KeyProvider`]. +/// +/// - `Some(provider)` delegates to the inner provider. +/// - `None` returns [`KeyProviderError::NotConfigured`], which makes it compose +/// naturally with [`FallbackKeyProvider`] — a `None` primary triggers the fallback. +/// +/// # Example +/// +/// ``` +/// use stack_kms::{ +/// FallbackKeyProvider, StaticKeyProvider, KeyProvider, ClientKey, +/// }; +/// use uuid::Uuid; +/// +/// # async fn example() { +/// # let fallback_key = ClientKey::from_hex_v1( +/// # Uuid::nil(), +/// # "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +/// # ).unwrap(); +/// // None means "no explicit key" — falls through to the profile store +/// let explicit_key: Option<StaticKeyProvider> = None; +/// let provider = FallbackKeyProvider::new( +/// explicit_key, +/// StaticKeyProvider::new(fallback_key), +/// ); +/// let key = provider.client_key().await.unwrap(); +/// # } +/// ``` +impl<T: KeyProvider> KeyProvider for Option<T> { + async fn client_key(&self) -> Result<ClientKey, KeyProviderError> { + match self { + Some(provider) => provider.client_key().await, + None => Err(KeyProviderError::NotConfigured( + "no explicit key provided".into(), + )), + } + } +} + +/// Tries a primary [`KeyProvider`], falling back to a secondary provider +/// when the primary returns [`KeyProviderError::NotConfigured`]. +/// +/// Other error variants ([`KeyProviderError::InvalidKey`], [`KeyProviderError::LoadError`]) +/// are **not** retried — they indicate the provider was found but broken. +/// +/// # Example +/// +/// ``` +/// use stack_kms::{ +/// EnvKeyProvider, StaticKeyProvider, FallbackKeyProvider, KeyProvider, ClientKey, +/// }; +/// use uuid::Uuid; +/// +/// # async fn example() { +/// # let fallback_key = ClientKey::from_hex_v1( +/// # Uuid::nil(), +/// # "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +/// # ).unwrap(); +/// // Try env vars first, fall back to a static key +/// let provider = FallbackKeyProvider::new( +/// EnvKeyProvider, +/// StaticKeyProvider::new(fallback_key), +/// ); +/// let key = provider.client_key().await.unwrap(); +/// # } +/// ``` +pub struct FallbackKeyProvider<P, F> { + primary: P, + fallback: F, +} + +impl<P: KeyProvider, F: KeyProvider> FallbackKeyProvider<P, F> { + /// Create a new [`FallbackKeyProvider`] with the given primary and fallback providers. + pub fn new(primary: P, fallback: F) -> Self { + Self { primary, fallback } + } +} + +impl<P: KeyProvider, F: KeyProvider> KeyProvider for FallbackKeyProvider<P, F> { + async fn client_key(&self) -> Result<ClientKey, KeyProviderError> { + match self.primary.client_key().await { + Ok(key) => { + tracing::debug!("using primary key provider"); + Ok(key) + } + Err(KeyProviderError::NotConfigured(_)) => { + tracing::debug!("primary key provider not configured, trying fallback"); + self.fallback.client_key().await + } + Err(e) => Err(e), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + + fn random_client_key() -> ClientKey { + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + let keyset = ProxyKeySet::generate(&ek_a, &ek_b); + ClientKey::new_v1(Uuid::new_v4(), keyset) + } + + /// A [`KeyProvider`] that always returns [`KeyProviderError::NotConfigured`]. + struct NotConfiguredProvider; + + impl KeyProvider for NotConfiguredProvider { + async fn client_key(&self) -> Result<ClientKey, KeyProviderError> { + Err(KeyProviderError::NotConfigured("not configured".into())) + } + } + + /// A [`KeyProvider`] that always returns [`KeyProviderError::InvalidKey`]. + struct InvalidKeyProvider; + + impl KeyProvider for InvalidKeyProvider { + async fn client_key(&self) -> Result<ClientKey, KeyProviderError> { + Err(KeyProviderError::InvalidKey("bad key".into())) + } + } + + mod static_provider { + use super::*; + + #[tokio::test] + async fn returns_the_wrapped_key() { + let expected_id = Uuid::new_v4(); + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + let keyset = ProxyKeySet::generate(&ek_a, &ek_b); + let client_key = ClientKey::new_v1(expected_id, keyset); + + let provider = StaticKeyProvider::new(client_key); + let result = provider.client_key().await.unwrap(); + + assert_eq!( + result.key_id, expected_id, + "should return the same key_id that was provided" + ); + } + } + + mod env_provider_parse { + use super::*; + + #[test] + fn returns_client_key_for_valid_inputs() { + let key = random_client_key(); + let uuid = key.key_id; + let hex = key.to_hex_v1().unwrap(); + + let result = EnvKeyProvider::parse(&uuid.to_string(), &hex).unwrap(); + + assert_eq!( + result.key_id, uuid, + "parsed key should have the same client_id" + ); + } + + mod given_invalid_uuid { + use super::*; + + #[test] + fn returns_invalid_key_error() { + let err = EnvKeyProvider::parse("not-a-uuid", "deadbeef").unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey for bad UUID, got: {err:?}" + ); + } + } + + mod given_valid_uuid_but_wrong_key_length { + use super::*; + + #[test] + fn returns_invalid_key_error() { + let uuid = Uuid::new_v4(); + // Valid hex but too short to be a valid keyset + let err = EnvKeyProvider::parse(&uuid.to_string(), "deadbeef").unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey for truncated key material, got: {err:?}" + ); + } + } + + mod given_invalid_encoding { + use super::*; + + #[test] + fn returns_invalid_key_error() { + let uuid = Uuid::new_v4(); + // `!!` is rejected by both hex and base64 decoders + let err = + EnvKeyProvider::parse(&uuid.to_string(), "not-valid-anything!!").unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey for unrecognised encoding, got: {err:?}" + ); + } + } + + mod given_base64_encoded_key { + use super::*; + use base64ct::Encoding; + + #[test] + fn returns_client_key_matching_hex_form() { + let key = random_client_key(); + let uuid = key.key_id; + let hex = key.to_hex_v1().unwrap(); + let bytes = base16ct::mixed::decode_vec(&hex).unwrap(); + let b64 = base64ct::Base64::encode_string(&bytes); + + let from_b64 = EnvKeyProvider::parse(&uuid.to_string(), &b64).unwrap(); + + assert_eq!( + from_b64.key_id, uuid, + "base64 input should decode to the same client_id" + ); + assert_eq!( + from_b64.to_hex_v1().unwrap(), + hex, + "base64 input should decode to the same key material as hex" + ); + } + } + } + + mod fallback_provider { + use super::*; + + mod given_primary_succeeds { + use super::*; + + #[tokio::test] + async fn returns_primary_key() { + let primary_key = random_client_key(); + let primary_id = primary_key.key_id; + let fallback_key = random_client_key(); + + let provider = FallbackKeyProvider::new( + StaticKeyProvider::new(primary_key), + StaticKeyProvider::new(fallback_key), + ); + + let result = provider.client_key().await.unwrap(); + + assert_eq!( + result.key_id, primary_id, + "should return the primary provider's key" + ); + } + } + + mod given_primary_not_configured { + use super::*; + + #[tokio::test] + async fn returns_fallback_key() { + let fallback_key = random_client_key(); + let fallback_id = fallback_key.key_id; + + let provider = FallbackKeyProvider::new( + NotConfiguredProvider, + StaticKeyProvider::new(fallback_key), + ); + + let result = provider.client_key().await.unwrap(); + + assert_eq!( + result.key_id, fallback_id, + "should fall through to the secondary provider" + ); + } + } + + mod given_primary_returns_invalid_key { + use super::*; + + #[tokio::test] + async fn does_not_fall_through() { + let fallback_key = random_client_key(); + + let provider = FallbackKeyProvider::new( + InvalidKeyProvider, + StaticKeyProvider::new(fallback_key), + ); + + let err = provider.client_key().await.unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "should propagate InvalidKey without trying fallback, got: {err:?}" + ); + } + } + } + + mod option_provider { + use super::*; + + #[tokio::test] + async fn some_delegates_to_inner() { + let key = random_client_key(); + let expected_id = key.key_id; + let provider: Option<StaticKeyProvider> = Some(StaticKeyProvider::new(key)); + + let result = provider.client_key().await.unwrap(); + + assert_eq!( + result.key_id, expected_id, + "Some(provider) should delegate to the inner provider" + ); + } + + #[tokio::test] + async fn none_returns_not_configured() { + let provider: Option<StaticKeyProvider> = None; + + let err = provider.client_key().await.unwrap_err(); + + assert!( + matches!(err, KeyProviderError::NotConfigured(_)), + "None should return NotConfigured, got: {err:?}" + ); + } + + #[tokio::test] + async fn none_triggers_fallback() { + let fallback_key = random_client_key(); + let fallback_id = fallback_key.key_id; + + let provider = FallbackKeyProvider::new( + Option::<StaticKeyProvider>::None, + StaticKeyProvider::new(fallback_key), + ); + + let result = provider.client_key().await.unwrap(); + + assert_eq!( + result.key_id, fallback_id, + "None primary should trigger fallback" + ); + } + + #[tokio::test] + async fn some_prevents_fallback() { + let primary_key = random_client_key(); + let primary_id = primary_key.key_id; + let fallback_key = random_client_key(); + + let provider = FallbackKeyProvider::new( + Some(StaticKeyProvider::new(primary_key)), + StaticKeyProvider::new(fallback_key), + ); + + let result = provider.client_key().await.unwrap(); + + assert_eq!( + result.key_id, primary_id, + "Some primary should prevent fallback" + ); + } + } +} diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs new file mode 100644 index 000000000..70e7f00ed --- /dev/null +++ b/packages/stack-kms/src/key_source.rs @@ -0,0 +1,531 @@ +//! An abstraction over the ZeroKMS data-key operations that higher-level +//! encryption layers (e.g. `stack-encrypt`) depend on. +//! +//! [`StackKms`](crate::StackKms) is the production implementation. Downstream +//! crates take a `K: DataKeySource` rather than a concrete client so their +//! encrypt/decrypt logic can be unit-tested against a deterministic in-memory +//! fake (see [`FakeDataKeySource`], enabled by the `test-support` feature) +//! without credentials or network access. + +use std::borrow::Cow; +use std::future::Future; + +use uuid::Uuid; +use zerokms_protocol::{IdentifiedBy, UnverifiedContext}; + +use crate::errors::Error; +use crate::key::{DataKey, DataKeyWithTag, IndexKey}; +use crate::maybe_send::MaybeSend; +use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; + +/// The slice of ZeroKMS data-key functionality required to encrypt and decrypt: +/// generating fresh data keys and re-deriving them for stored ciphertexts. +/// +/// Both methods take an owned `Vec` of payloads (rather than `impl IntoIterator`) +/// so the trait stays simple to implement and the returned futures are easy to +/// box behind an async `vitaminc_aead::Decipher`. +/// +/// The returned futures are bounded by [`MaybeSend`]: `Send` on native targets +/// so callers can drive them on a multi-threaded runtime, unbounded on wasm32 +/// (see [`MaybeSend`] for why). +pub trait DataKeySource { + /// Generate one fresh data key per payload, in payload order. + fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> impl Future<Output = Result<Vec<DataKeyWithTag>, Error>> + MaybeSend; + + /// Re-derive one data key per payload, in payload order. Each payload's IV + + /// tag (returned by a prior [`generate_keys`](DataKeySource::generate_keys) + /// call and stored with the ciphertext) must reproduce the same key. + fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> impl Future<Output = Result<Vec<DataKey>, Error>> + MaybeSend; +} + +/// The slice of ZeroKMS functionality required to *index* encrypted data: +/// loading the deterministic per-keyset [`IndexKey`] used to generate index +/// terms (Searchable Encrypted Metadata) with PRFs and similar constructions. +/// +/// Split from [`DataKeySource`] because the two capabilities are consumed +/// separately: record encryption needs data keys, term generation needs the +/// index key. Production implementations provide both. +/// +/// As with [`DataKeySource`], the returned future is bounded by [`MaybeSend`]. +pub trait IndexKeySource { + /// Load the index key for a keyset — identified by id or name, or the + /// client's default keyset when `keyset_id` is `None`. Returns the + /// resolved keyset id alongside the key, so callers pinning `None` or a + /// name learn which keyset they resolved to. + /// + /// The index key is deterministic per keyset: loading it twice yields the + /// same key, so terms generated at write time match terms generated at + /// query time. + fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> impl Future<Output = Result<(Uuid, IndexKey), Error>> + MaybeSend; +} + +impl<C, Conn> DataKeySource for crate::StackKms<C, Conn> +where + C: stack_auth::AuthStrategyBounds, + for<'a> &'a C: stack_auth::AuthStrategy, + Conn: crate::ZeroKMSConnection + Send + Sync, +{ + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, Error> { + // Disambiguate from the trait method of the same name: the inherent + // method takes `impl IntoIterator`, which `Vec` satisfies. + crate::StackKms::generate_keys(self, payloads, keyset_id, unverified_context).await + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, Error> { + crate::StackKms::retrieve_keys(self, payloads, keyset_id, unverified_context).await + } +} + +impl<C, Conn> IndexKeySource for crate::StackKms<C, Conn> +where + C: stack_auth::AuthStrategyBounds, + for<'a> &'a C: stack_auth::AuthStrategy, + Conn: crate::ZeroKMSConnection + Send + Sync, +{ + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, IndexKey), Error> { + let (keyset, index_key) = self.load_keyset(keyset_id).await?; + Ok((keyset.id, index_key)) + } +} + +#[cfg(feature = "test-support")] +mod fake { + use super::*; + use crate::errors::{GenerateKeyError, RetrieveKeyError}; + use crate::key::DataKey; + use recipher::key::{Iv, Key}; + use sha2::{Digest, Sha256}; + use std::collections::HashMap; + use std::sync::Mutex; + use vitaminc::random::{Generatable, SafeRand}; + + /// An in-memory stub [`DataKeySource`] for tests and examples that need + /// `generate_keys` → `retrieve_keys` to round-trip without ZeroKMS + /// credentials or network access. + /// + /// `generate_keys` hands out a random key, IV and tag per payload and + /// remembers the key under `(iv, tag)`; `retrieve_keys` looks each payload + /// up by the same pair and fails with + /// [`RetrieveKeyError::FailedRetrieval`] when there is no such key. That is + /// the whole contract. + /// + /// **This stub models none of ZeroKMS's authorization semantics.** The + /// `descriptor`, `context`, `keyset_id` and `decryption_policy` on a + /// payload are accepted and ignored (the policy is echoed back on the + /// generated key, as the real service returns the resolved policy for + /// storage). Nothing is derived, resolved, or verified — a wrong + /// descriptor, a stripped context, an unresolved policy condition or a + /// different caller all retrieve just fine here. Those decisions are + /// ZeroKMS's, tested in `vitur-server-core`; do not assert them against + /// this stub. Consumers testing *what they send* should mock the trait. + /// + /// As an [`IndexKeySource`] the index key is a fixed function of the + /// resolved keyset id (and a name resolves to a fixed v5 UUID), so two + /// independently built stubs agree — search terms generated by one cipher + /// must match terms generated by another, and known-answer tests can pin + /// term bytes. That is plain determinism, not ZeroKMS's derivation. + #[derive(Debug, Default)] + pub struct FakeDataKeySource { + keys: Mutex<HashMap<(Iv, Vec<u8>), Key>>, + } + + /// UUID namespace for name resolution: `Uuid::new_v5` mints deterministic, + /// RFC 4122-valid UUIDs that strict validation downstream accepts. + const KEYSET_NAME_NAMESPACE: Uuid = Uuid::from_u128(0x8ff8_1a03_4b2d_4f0b_9d6e_5a1c_3f7e_2b41); + + impl FakeDataKeySource { + /// The name the fake reserves for the client's default keyset: + /// resolving `IdentifiedBy::Name("default")` reaches the same keyset + /// as passing `None` or the nil UUID. Real ZeroKMS resolves whatever + /// name the default keyset was created under; the fake fixes it to + /// this constant so name-based tests can address the default keyset. + pub const DEFAULT_KEYSET_NAME: &'static str = "default"; + + pub fn new() -> Self { + Self::default() + } + + /// Number of keys generated so far and available to retrieve. + pub fn len(&self) -> usize { + self.lock().len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<(Iv, Vec<u8>), Key>> { + lock(&self.keys) + } + } + + /// A poisoned lock only means another test thread panicked mid-insert; + /// the map is still a valid map. + fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn random<T: Generatable>(rng: &mut SafeRand) -> Result<T, Error> { + Ok(Generatable::random(rng).map_err(GenerateKeyError::GenerateIv)?) + } + + impl IndexKeySource for FakeDataKeySource { + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, IndexKey), Error> { + let resolved = match keyset_id { + None => Uuid::nil(), + Some(IdentifiedBy::Uuid(id)) => id, + Some(IdentifiedBy::Name(name)) + if &*name == FakeDataKeySource::DEFAULT_KEYSET_NAME => + { + Uuid::nil() + } + Some(IdentifiedBy::Name(name)) => { + Uuid::new_v5(&KEYSET_NAME_NAMESPACE, name.as_bytes()) + } + }; + let mut hasher = Sha256::new(); + hasher.update(b"stack-kms::FakeDataKeySource::index-key::v1"); + hasher.update(resolved.as_bytes()); + let key: Key = hasher.finalize().into(); + Ok((resolved, IndexKey::from(key))) + } + } + + impl DataKeySource for FakeDataKeySource { + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + _keyset_id: Option<Uuid>, + _unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, Error> { + let mut rng = SafeRand::from_entropy().map_err(GenerateKeyError::GenerateIv)?; + let mut keys = self.lock(); + payloads + .into_iter() + .map(|payload| { + let iv: Iv = random(&mut rng)?; + let key: Key = random(&mut rng)?; + let tag: [u8; 32] = random(&mut rng)?; + let _ = keys.insert((iv, tag.to_vec()), key); + Ok(DataKeyWithTag { + key: DataKey { iv, key }, + tag: tag.to_vec(), + decryption_policy: payload.decryption_policy, + }) + }) + .collect() + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + _keyset_id: Option<Uuid>, + _unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, Error> { + let keys = self.lock(); + payloads + .iter() + .map(|p| { + let iv: Iv = *p.iv.as_ref(); + keys.get(&(iv, p.tag.to_vec())) + .map(|key| DataKey { iv, key: *key }) + .ok_or_else(|| { + Error::RetrieveKey(RetrieveKeyError::FailedRetrieval( + "no key was generated with this iv and tag".to_string(), + )) + }) + }) + .collect() + } + } +} + +#[cfg(feature = "test-support")] +pub use fake::FakeDataKeySource; + +#[cfg(all(test, feature = "test-support"))] +mod tests { + use super::*; + use crate::errors::RetrieveKeyError; + use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; + use std::borrow::Cow; + use zerokms_protocol::{DecryptionPolicy, PolicyCondition}; + + async fn generate_one(src: &FakeDataKeySource, descriptor: &str) -> DataKeyWithTag { + src.generate_keys( + vec![GenerateKeyPayload::new(descriptor, Cow::Owned(vec![]))], + None, + None, + ) + .await + .unwrap() + .remove(0) + } + + async fn retrieve_one( + src: &FakeDataKeySource, + payload: RetrieveKeyPayload<'_>, + ) -> Result<DataKey, Error> { + src.retrieve_keys(vec![payload], None, None) + .await + .map(|mut keys| keys.remove(0)) + } + + fn assert_not_found(result: Result<DataKey, Error>, what: &str) { + match result { + Err(Error::RetrieveKey(RetrieveKeyError::FailedRetrieval(_))) => {} + Err(other) => panic!("{what}: expected FailedRetrieval, got {other:?}"), + Ok(_) => panic!("{what}: expected rejection, got a key"), + } + } + + #[tokio::test] + async fn fake_index_key_is_deterministic_per_keyset() { + let src = FakeDataKeySource::new(); + let keyset_a = Uuid::from_u128(1); + let keyset_b = Uuid::from_u128(2); + + let (id_a, key_a) = src.load_index_key(Some(keyset_a.into())).await.unwrap(); + let (_, key_a_again) = src.load_index_key(Some(keyset_a.into())).await.unwrap(); + let (_, key_b) = src.load_index_key(Some(keyset_b.into())).await.unwrap(); + let (id_none, _) = src.load_index_key(None).await.unwrap(); + + assert_eq!(id_a, keyset_a); + assert_eq!(key_a.key(), key_a_again.key()); + assert_ne!(key_a.key(), key_b.key()); + assert_eq!(id_none, Uuid::nil()); + } + + #[tokio::test] + async fn independently_built_stubs_agree_on_index_keys() { + // Search terms generated through one cipher must match terms generated + // through another, so the index key cannot be per-instance state. + let (_, a) = FakeDataKeySource::new().load_index_key(None).await.unwrap(); + let (_, b) = FakeDataKeySource::new().load_index_key(None).await.unwrap(); + assert_eq!(a.key(), b.key()); + } + + #[tokio::test] + async fn fake_index_key_resolves_names_deterministically() { + let src = FakeDataKeySource::new(); + // `InvalidNameError` has no `Debug`, so go via `ok()`. + let by_name = |n: &str| zerokms_protocol::IdentifiedBy::Name(n.try_into().ok().unwrap()); + + let (id_a, key_a) = src.load_index_key(Some(by_name("users"))).await.unwrap(); + let (id_a_again, key_a_again) = src.load_index_key(Some(by_name("users"))).await.unwrap(); + let (id_b, key_b) = src.load_index_key(Some(by_name("orders"))).await.unwrap(); + // Pinning the resolved id must reach the same keyset as the name. + let (_, key_a_by_id) = src.load_index_key(Some(id_a.into())).await.unwrap(); + + assert_eq!(id_a, id_a_again); + assert_eq!(key_a.key(), key_a_again.key()); + assert_ne!(id_a, id_b); + assert_ne!(key_a.key(), key_b.key()); + assert_eq!(key_a.key(), key_a_by_id.key()); + } + + #[tokio::test] + async fn fake_name_resolution_mints_rfc4122_uuids() { + let src = FakeDataKeySource::new(); + let by_name = |n: &str| zerokms_protocol::IdentifiedBy::Name(n.try_into().ok().unwrap()); + + let (id, _) = src.load_index_key(Some(by_name("users"))).await.unwrap(); + + // Strict UUID validation downstream must accept the minted ids. + assert_eq!(id.get_version_num(), 5); + assert_eq!(id.get_variant(), uuid::Variant::RFC4122); + } + + #[tokio::test] + async fn the_default_keyset_name_is_equivalent_to_none() { + // Real ZeroKMS resolves the default keyset's name to the same keyset + // as `None`, so keys and index terms written under `None` must be + // reachable by the fake's reserved default-keyset name too. + let src = FakeDataKeySource::new(); + let by_name = |n: &str| zerokms_protocol::IdentifiedBy::Name(n.try_into().ok().unwrap()); + + let (id_none, key_none) = src.load_index_key(None).await.unwrap(); + let (id_name, key_name) = src + .load_index_key(Some(by_name(FakeDataKeySource::DEFAULT_KEYSET_NAME))) + .await + .unwrap(); + + assert_eq!(id_none, id_name); + assert_eq!(key_none.key(), key_name.key()); + } + + #[tokio::test] + async fn none_resolves_to_the_same_index_key_as_the_reported_default_keyset() { + // The pin-the-resolved-id pattern advertised by + // `IndexKeySource::load_index_key`: loading under `None` and then under + // the id it reported must yield the same index key. + let src = FakeDataKeySource::new(); + let (resolved, key_none) = src.load_index_key(None).await.unwrap(); + let (_, key_resolved) = src.load_index_key(Some(resolved.into())).await.unwrap(); + assert_eq!(key_none.key(), key_resolved.key()); + } + + #[tokio::test] + async fn generate_then_retrieve_reproduces_the_key() { + let src = FakeDataKeySource::new(); + let dk = generate_one(&src, "users/email").await; + + let retrieved = retrieve_one( + &src, + RetrieveKeyPayload::new(dk.key.iv, "users/email", &dk.tag), + ) + .await + .unwrap(); + + assert_eq!(dk.key.key(), retrieved.key()); + assert_eq!(dk.key.iv, retrieved.iv); + assert_eq!(src.len(), 1); + } + + #[tokio::test] + async fn every_generated_key_is_distinct() { + let src = FakeDataKeySource::new(); + let keys = src + .generate_keys( + vec![ + GenerateKeyPayload::new("a", Cow::Owned(vec![])), + GenerateKeyPayload::new("a", Cow::Owned(vec![])), + ], + None, + None, + ) + .await + .unwrap(); + + assert_ne!(keys[0].key.iv, keys[1].key.iv); + assert_ne!(keys[0].tag, keys[1].tag); + assert_ne!(keys[0].key.key(), keys[1].key.key()); + assert_eq!(src.len(), 2); + } + + #[tokio::test] + async fn an_unknown_tag_or_iv_is_not_found() { + let src = FakeDataKeySource::new(); + let dk = generate_one(&src, "d").await; + + assert_not_found( + retrieve_one(&src, RetrieveKeyPayload::new(dk.key.iv, "d", b"wrong-tag")).await, + "wrong tag", + ); + + let mut other_iv = dk.key.iv; + other_iv[15] ^= 0xff; + assert_not_found( + retrieve_one(&src, RetrieveKeyPayload::new(other_iv, "d", &dk.tag)).await, + "wrong iv", + ); + + assert_not_found( + retrieve_one( + &FakeDataKeySource::new(), + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), + ) + .await, + "a different stub instance", + ); + } + + #[tokio::test] + async fn a_batch_with_one_unknown_key_fails_as_a_whole() { + let src = FakeDataKeySource::new(); + let dk = generate_one(&src, "d").await; + + let result = src + .retrieve_keys( + vec![ + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), + RetrieveKeyPayload::new(dk.key.iv, "d", b"wrong-tag"), + ], + None, + None, + ) + .await; + assert!( + matches!( + result, + Err(Error::RetrieveKey(RetrieveKeyError::FailedRetrieval(_))) + ), + "one unknown key must fail the batch" + ); + } + + #[tokio::test] + async fn the_policy_is_echoed_back_and_nothing_else_is_interpreted() { + // Pins the documented non-contract: descriptor, context, keyset and + // policy are not consulted on retrieval. If this test starts failing + // because someone made the stub "smarter", read the type's docs first. + let src = FakeDataKeySource::new(); + let policy = DecryptionPolicy { + conditions: vec![PolicyCondition { + claim: "sub".into(), + value: None, + }], + }; + let dk = src + .generate_keys( + vec![GenerateKeyPayload::new("d", Cow::Owned(vec![])) + .with_decryption_policy(policy.clone())], + Some(Uuid::new_v4()), + None, + ) + .await + .unwrap() + .remove(0); + assert_eq!(dk.decryption_policy, Some(policy)); + + let retrieved = src + .retrieve_keys( + vec![RetrieveKeyPayload::new( + dk.key.iv, + "something-else", + &dk.tag, + )], + Some(Uuid::new_v4()), + None, + ) + .await + .unwrap() + .remove(0); + assert_eq!(dk.key.key(), retrieved.key()); + } + + #[test] + fn the_stub_is_send_and_sync() { + fn assert_send_sync<T: Send + Sync>() {} + assert_send_sync::<FakeDataKeySource>(); + } +} diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs new file mode 100644 index 000000000..c653e5bf2 --- /dev/null +++ b/packages/stack-kms/src/lib.rs @@ -0,0 +1,205 @@ +#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +//! `stack-kms` is a focused client for ZeroKMS **data key** operations: +//! generating new data keys and retrieving existing ones. +//! +//! It is an extraction of the key-generation/retrieval slice of +//! `cipherstash-client`'s `zerokms` module into a standalone crate. This first +//! cut deliberately covers only: +//! +//! * [`StackKms::generate_keys`] — derive fresh data keys (with tags) from ZeroKMS +//! * [`StackKms::retrieve_keys`] / [`StackKms::retrieve_keys_fallible`] — re-derive +//! data keys for previously encrypted records +//! * [`StackKms::load_keyset`] — load a keyset and derive its deterministic +//! [`IndexKey`], used to generate index terms (Searchable Encrypted Metadata) +//! +//! Encryption/decryption, keyset and client management, and config save/load +//! all remain in `cipherstash-client` for now. +//! +//! # Quick start +//! +// The quick start goes through `StackKmsBuilder`, which configures the default +// reqwest transport and so only exists with `http`. Without the feature the +// entry point is `StackKms::connect` over the host's own `ZeroKMSConnection`. +#![cfg_attr( + feature = "http", + doc = r#"```no_run +use stack_kms::{StackKmsBuilder, GenerateKeyPayload}; +use std::borrow::Cow; + +# async fn example() -> Result<(), Box<dyn std::error::Error>> { +// Credentials + client key are discovered from the environment: +// CS_CLIENT_ID / CS_CLIENT_KEY for the key, AutoStrategy for the token. +let kms = StackKmsBuilder::auto()? + .with_key_provider(stack_kms::EnvKeyProvider) + .build() + .await?; + +let keys = kms + .generate_keys( + [GenerateKeyPayload::new("users/email", Cow::Owned(vec![]))], + None, + None, + ) + .await?; + +assert_eq!(keys.len(), 1); +# Ok(()) +# } +```"# +)] +#![cfg_attr( + not(feature = "http"), + doc = "Without the `http` feature the crate ships no transport: implement\ + [`ZeroKMSConnection`] over the host's own HTTP and build the client with\ + [`StackKms::connect`]. Enable `http` for the bundled reqwest transport and its\ + `StackKmsBuilder`." +)] +// Security lints — see `.claude/skills/rust-security`. This crate handles +// ZeroKMS key material, so `mem::forget` (which would bypass `ZeroizeOnDrop`) +// and any accidental console output are denied/warned against. +#![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)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Relax in tests +#![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))] + +#[cfg(feature = "http")] +mod builder; +mod client; +mod connection; +mod endpoint; +mod errors; +mod futures; +mod key; +mod key_provider; +mod key_source; +mod maybe_send; +mod payload; +mod secret_key; +#[cfg(feature = "http")] +mod user_agent; +pub mod vars; + +// Builder (configures the default HTTP transport) +#[cfg(feature = "http")] +pub use builder::{StackKmsBuilder, StackKmsBuilderError, WithKeyProvider}; + +// Clients +pub use client::{ + Client, ClientOpts, FallibleDataKeyVec, InvalidClientOpts, StackKms, DEFAULT_CONCURRENT_REQS, + DEFAULT_KEYS_PER_REQ, +}; + +// Transport +#[cfg(feature = "http")] +pub use connection::{ConnectionInitError, HttpConnection, HttpConnectionOpts}; +pub use connection::{ZeroKMSConnection, ZeroKMSConnectionInit}; +// Transport-independent response classification, shared by `HttpConnection` +// and by hosts that bring their own transport (the WASI guest). +pub use connection::{ + classify_response, is_json_content_type, BaseUrlUnresolved, FailureResponse, + UnexpectedContentType, +}; +pub use endpoint::{InvalidEndpoint, ZeroKmsEndpoint}; + +// The native/wasm32 Send split for the async traits' returned futures +pub use maybe_send::MaybeSend; + +// Errors +pub use errors::{ + Error, GenerateKeyError, InvalidKeyMaterialError, LoadKeysetError, RetrieveKeyError, +}; + +// Key material +pub use key::{ClientKey, DataKey, DataKeyWithTag, IndexKey, V1KeySet}; + +// Key source abstractions (production = `StackKms`; tests = `FakeDataKeySource`) +#[cfg(feature = "test-support")] +pub use key_source::FakeDataKeySource; +pub use key_source::{DataKeySource, IndexKeySource}; + +// Key providers +pub use key_provider::{ + EnvKeyProvider, FallbackKeyProvider, KeyProvider, KeyProviderError, StaticKeyProvider, +}; +pub use secret_key::SecretKey; +// `KeyProvider` is implemented for `ProfileStore` (the CLI's on-disk profile), +// so callers need to be able to name it without depending on `stack-profile`. +#[cfg(all(feature = "profile", not(target_arch = "wasm32")))] +pub use stack_profile::ProfileStore; + +// Operation payloads +pub use payload::{GenerateKeyPayload, RetrieveKeyPayload}; + +// Commonly needed re-exports from the protocol / crypto layers +pub use recipher::key::{GenRandom, Iv}; +pub use zerokms_protocol::{ + Context, DecryptionPolicy, IdentifiedBy, KeyId, Keyset, UnverifiedContext, ViturKeyMaterial, + MAX_DESCRIPTOR_LEN, +}; + +/// Process-wide environment guard for tests that set or clear env vars. +/// +/// `cargo nextest` runs each test in its own process, but plain `cargo test` +/// runs them as threads of one process, so env-mutating tests serialise on a +/// global lock and restore the prior values on drop. +#[cfg(test)] +pub(crate) mod test_env { + use std::sync::{Mutex, MutexGuard}; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + pub(crate) struct ScopedEnv { + previous: Vec<(&'static str, Option<String>)>, + _guard: MutexGuard<'static, ()>, + } + + impl ScopedEnv { + /// Lock the environment, then set (`Some`) or clear (`None`) each + /// variable for the lifetime of the returned guard. + pub(crate) fn new(vars: &[(&'static str, Option<&str>)]) -> Self { + let guard = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = vars + .iter() + .map(|(name, value)| { + let prior = std::env::var(name).ok(); + match value { + Some(v) => std::env::set_var(name, v), + None => std::env::remove_var(name), + } + (*name, prior) + }) + .collect(); + Self { + previous, + _guard: guard, + } + } + } + + impl Drop for ScopedEnv { + fn drop(&mut self) { + for (name, prior) in self.previous.drain(..) { + match prior { + Some(v) => std::env::set_var(name, v), + None => std::env::remove_var(name), + } + } + } + } +} diff --git a/packages/stack-kms/src/maybe_send.rs b/packages/stack-kms/src/maybe_send.rs new file mode 100644 index 000000000..f07ca75e1 --- /dev/null +++ b/packages/stack-kms/src/maybe_send.rs @@ -0,0 +1,32 @@ +//! The single place holding this crate's native/wasm32 `Send` split. +//! +//! Async traits here ([`DataKeySource`](crate::DataKeySource), +//! [`IndexKeySource`](crate::IndexKeySource), +//! [`ZeroKMSConnection`](crate::ZeroKMSConnection)) want their returned +//! futures `Send` on native targets — so callers can drive them on a +//! multi-threaded runtime — but not on wasm32, where the fetch-backed HTTP and +//! auth futures aren't `Send` and edge runtimes are single-threaded anyway +//! (mirroring `stack_auth::AuthStrategy`). Bounding those futures with +//! [`MaybeSend`] lets each trait be defined once for both targets instead of +//! as a duplicated `#[cfg]` pair that default-target CI only half +//! type-checks. + +/// Alias for `Send` on native targets; satisfied by every type on wasm32. +/// +/// Never implement this manually — the blanket impl covers everything the +/// target allows. +#[cfg(not(target_arch = "wasm32"))] +pub trait MaybeSend: Send {} + +#[cfg(not(target_arch = "wasm32"))] +impl<T: Send + ?Sized> MaybeSend for T {} + +/// Alias for `Send` on native targets; satisfied by every type on wasm32. +/// +/// Never implement this manually — the blanket impl covers everything the +/// target allows. +#[cfg(target_arch = "wasm32")] +pub trait MaybeSend {} + +#[cfg(target_arch = "wasm32")] +impl<T: ?Sized> MaybeSend for T {} diff --git a/packages/stack-kms/src/payload.rs b/packages/stack-kms/src/payload.rs new file mode 100644 index 000000000..0c4ada822 --- /dev/null +++ b/packages/stack-kms/src/payload.rs @@ -0,0 +1,131 @@ +use recipher::key::Iv; +use std::borrow::Cow; +use zerokms_protocol::{Context, DecryptionPolicy, KeyId, RetrieveKeySpec}; + +/// The requirements for generating a data key from ZeroKMS. +#[derive(Clone)] +pub struct GenerateKeyPayload<'a> { + pub descriptor: &'a str, + pub(crate) context: Cow<'a, [Context]>, + pub decryption_policy: Option<DecryptionPolicy>, +} + +impl<'a> GenerateKeyPayload<'a> { + /// Create a new [`GenerateKeyPayload`] with the given descriptor and context. + pub fn new(descriptor: &'a str, context: Cow<'a, [Context]>) -> Self { + Self { + descriptor, + context, + decryption_policy: None, + } + } + + pub fn with_decryption_policy(mut self, policy: DecryptionPolicy) -> Self { + self.decryption_policy = Some(policy); + self + } +} + +/// The requirements for retrieving a data key from ZeroKMS. +pub struct RetrieveKeyPayload<'a> { + pub iv: KeyId, + pub descriptor: &'a str, + pub tag: &'a [u8], + pub context: Cow<'a, [Context]>, + pub decryption_policy: Option<DecryptionPolicy>, +} + +impl<'a> RetrieveKeyPayload<'a> { + /// Create a new [`RetrieveKeyPayload`] with the given IV, descriptor, and tag. + pub fn new(iv: Iv, descriptor: &'a str, tag: &'a [u8]) -> Self { + Self { + iv: KeyId::from(iv), + descriptor, + tag, + context: Default::default(), + decryption_policy: None, + } + } + + pub fn with_context(mut self, context: Cow<'a, [Context]>) -> Self { + self.context = context; + self + } + + pub fn with_decryption_policy(mut self, policy: DecryptionPolicy) -> Self { + self.decryption_policy = Some(policy); + self + } +} + +impl<'a> From<RetrieveKeyPayload<'a>> for RetrieveKeySpec<'a> { + fn from( + RetrieveKeyPayload { + iv, + descriptor, + tag, + context, + decryption_policy, + }: RetrieveKeyPayload<'a>, + ) -> Self { + let mut spec = Self::new(iv, tag, descriptor).with_context(context); + if let Some(policy) = decryption_policy { + spec = spec.with_policy(policy); + } + spec + } +} + +#[cfg(test)] +mod tests { + use super::*; + use zerokms_protocol::PolicyCondition; + + fn policy() -> DecryptionPolicy { + DecryptionPolicy { + conditions: vec![PolicyCondition { + claim: "sub".into(), + value: Some("alice".into()), + }], + } + } + + mod retrieve_key_spec_from_payload { + use super::*; + + #[test] + fn carries_iv_descriptor_tag_and_context_without_a_policy() { + let iv: Iv = [7u8; 16]; + let ctx = vec![Context::Tag("tenant-1".into())]; + let payload = RetrieveKeyPayload::new(iv, "users/email", b"tag") + .with_context(Cow::Borrowed(&ctx)); + + let spec = RetrieveKeySpec::from(payload); + + assert_eq!(spec.iv, KeyId::from(iv)); + assert_eq!(spec.descriptor, "users/email"); + assert_eq!(spec.tag.as_ref(), b"tag"); + assert_eq!(spec.context.len(), 1); + assert!(spec.decryption_policy.is_none()); + } + + #[test] + fn forwards_the_policy_when_present() { + let payload = + RetrieveKeyPayload::new([0u8; 16], "d", b"tag").with_decryption_policy(policy()); + + let spec = RetrieveKeySpec::from(payload); + + assert_eq!(spec.decryption_policy, Some(policy())); + } + } + + #[test] + fn generate_key_payload_with_decryption_policy_sets_the_policy() { + let payload = GenerateKeyPayload::new("d", Cow::Owned(vec![])); + assert!(payload.decryption_policy.is_none()); + + let payload = payload.with_decryption_policy(policy()); + assert_eq!(payload.decryption_policy, Some(policy())); + } +} diff --git a/packages/stack-kms/src/secret_key.rs b/packages/stack-kms/src/secret_key.rs new file mode 100644 index 000000000..b0ee97053 --- /dev/null +++ b/packages/stack-kms/src/secret_key.rs @@ -0,0 +1,460 @@ +use base64ct::Encoding; +use serde::{Deserialize, Serialize}; +#[cfg(all(feature = "profile", not(target_arch = "wasm32")))] +use stack_profile::{ProfileData, ProfileError, ProfileStore}; +use uuid::Uuid; +use vitaminc::protected::OpaqueDebug; +use zeroize::{Zeroize, ZeroizeOnDrop}; +use zerokms_protocol::ViturKeyMaterial; + +use crate::key::ClientKey; +use crate::key_provider::{KeyProvider, KeyProviderError}; + +/// Decode client key material accepting either hex (preferred) or standard padded base64. +/// +/// `secretkey.json` serialises key material as base64 (via `ViturKeyMaterial`'s serde), +/// while `CS_CLIENT_KEY` has historically been hex. Tolerating either at parse time +/// means users can copy-paste between the two without re-encoding. +/// +/// Tries hex first via `base16ct::mixed::decode_vec` (constant-time). Falls back to +/// `base64ct::Base64::decode_vec` (also constant-time) only if the hex parse fails — +/// which it does for any string containing `+`, `/`, `=`, or other non-hex chars. +pub(crate) fn decode_client_key_material(s: &str) -> Result<Vec<u8>, String> { + if let Ok(bytes) = base16ct::mixed::decode_vec(s) { + return Ok(bytes); + } + base64ct::Base64::decode_vec(s) + .map_err(|e| format!("invalid encoding (expected hex or base64): {e}")) +} + +/// A device-scoped client key, stored in `secretkey.json` within the profile directory. +/// +/// The key material is zeroized on drop and hidden from debug output. +/// +/// # Example +/// +/// ```no_run +/// # async fn example() -> Result<(), Box<dyn std::error::Error>> { +/// use stack_kms::{SecretKey, KeyProvider}; +/// use zerokms_protocol::ViturKeyMaterial; +/// use uuid::Uuid; +/// +/// let key_material: ViturKeyMaterial = vec![/* key bytes */].into(); +/// let secret_key = SecretKey::new( +/// Uuid::new_v4(), +/// key_material, +/// ); +/// +/// // SecretKey implements KeyProvider +/// let client_key = secret_key.client_key().await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Serialize, Deserialize, Zeroize, ZeroizeOnDrop, OpaqueDebug)] +pub struct SecretKey { + /// The client ID returned by ZeroKMS when creating a device keyset. + #[zeroize(skip)] + client_id: Uuid, + /// The client key material for this device. + client_key: ViturKeyMaterial, +} + +impl SecretKey { + /// Create a new [`SecretKey`] from the given client ID and key material. + pub fn new(client_id: Uuid, client_key: ViturKeyMaterial) -> Self { + Self { + client_id, + client_key, + } + } + + /// Create a [`SecretKey`] from string representations of the client ID and encoded + /// key material. + /// + /// Accepts the key material as either **hex** (the historical `CS_CLIENT_KEY` format) + /// **or** standard padded **base64** (the format used by `secretkey.json` on disk). + /// Hex is tried first; base64 is a fallback for any input that doesn't parse as hex. + /// + /// Named `from_hex` for historical reasons — the function is now lenient. The name + /// is preserved to avoid breaking callers. + /// + /// # Errors + /// + /// Returns [`KeyProviderError::InvalidKey`] if: + /// - `client_id` is not a valid UUID + /// - `client_key_encoded` is neither valid hex nor valid base64 + pub fn from_hex( + client_id: String, + mut client_key_encoded: String, + ) -> Result<Self, KeyProviderError> { + // Decode and zeroize the encoded key material *first*, so no later + // fallible step (e.g. the UUID parse below) can early-return and leave + // the secret sitting un-zeroized in the owned `String`. + let result = decode_client_key_material(&client_key_encoded); + client_key_encoded.zeroize(); + + let mut bytes = + result.map_err(|e| KeyProviderError::InvalidKey(format!("invalid client_key: {e}")))?; + + let uuid = match Uuid::parse_str(&client_id) { + Ok(uuid) => uuid, + Err(e) => { + // The decoded key material is now the live copy of the secret — + // zeroize it before returning rather than dropping the plain `Vec`. + bytes.zeroize(); + return Err(KeyProviderError::InvalidKey(format!( + "invalid client_id: {e}" + ))); + } + }; + + Ok(Self::new(uuid, ViturKeyMaterial::from(bytes))) + } + + /// Load a [`SecretKey`] from the `CS_CLIENT_ID` and `CS_CLIENT_KEY` environment variables. + /// + /// `CS_CLIENT_KEY` accepts either hex (the historical format) or the base64 value + /// that appears in `secretkey.json` — see [`SecretKey::from_hex`]. + /// + /// Returns `Ok(None)` if neither or only one variable is set, allowing callers to + /// fall back to another source. Returns `Err` if both variables are present but + /// the values are invalid (bad UUID or bad encoding). + /// + /// # Example + /// + /// ```no_run + /// use stack_kms::SecretKey; + /// + /// let key = SecretKey::from_env().expect("invalid key material in env"); + /// // key is Option<SecretKey> — None means "not configured via env" + /// ``` + #[cfg(not(target_arch = "wasm32"))] + pub fn from_env() -> Result<Option<Self>, KeyProviderError> { + use crate::vars::{CS_CLIENT_ID, CS_CLIENT_KEY}; + + // Check the ID first and only then read the key: reading `CS_CLIENT_KEY` + // when it can't be used would leave an owned copy of the key material + // to be dropped un-zeroized. + let Ok(id) = std::env::var(CS_CLIENT_ID) else { + tracing::debug!("{CS_CLIENT_ID} not set, skipping env secret key"); + return Ok(None); + }; + let Ok(key) = std::env::var(CS_CLIENT_KEY) else { + tracing::debug!("{CS_CLIENT_ID} set but {CS_CLIENT_KEY} missing, skipping"); + return Ok(None); + }; + tracing::debug!("both {CS_CLIENT_ID} and {CS_CLIENT_KEY} set, loading secret key"); + Self::from_hex(id, key).map(Some) + } +} + +/// Implement [ProfileData] for [SecretKey] to enable loading/saving from the profile directory. +#[cfg(all(feature = "profile", not(target_arch = "wasm32")))] +impl ProfileData for SecretKey { + const FILENAME: &'static str = "secretkey.json"; + const MODE: Option<u32> = Some(0o600); +} + +/// Implement [KeyProvider] for [SecretKey] to allow it to be used directly as +/// a key source when initializing a client. +// The builder it names only exists with `http`. +#[cfg_attr( + feature = "http", + doc = "See [`StackKmsBuilder`](super::StackKmsBuilder).\n" +)] +impl KeyProvider for SecretKey { + async fn client_key(&self) -> Result<ClientKey, KeyProviderError> { + ClientKey::from_bytes(self.client_id, &self.client_key) + .map_err(|e| KeyProviderError::InvalidKey(e.to_string())) + } +} + +#[cfg(all(feature = "profile", not(target_arch = "wasm32")))] +impl KeyProvider for ProfileStore { + async fn client_key(&self) -> Result<ClientKey, KeyProviderError> { + let ws_store = self.current_workspace_store().map_err(|e| match e { + ProfileError::NoCurrentWorkspace => KeyProviderError::NotConfigured(e.to_string()), + _ => KeyProviderError::LoadError(e.to_string()), + })?; + let secret_key: SecretKey = ws_store.load_profile().map_err(|e| match e { + ProfileError::NotFound { .. } => KeyProviderError::NotConfigured(e.to_string()), + _ => KeyProviderError::LoadError(e.to_string()), + })?; + secret_key.client_key().await + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + + /// Build a random `SecretKey` and return it alongside the `client_id` and raw keyset bytes. + fn random_secret_key() -> (SecretKey, Uuid, Vec<u8>) { + let client_id = Uuid::new_v4(); + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + let keyset = ProxyKeySet::generate(&ek_a, &ek_b); + let bytes = keyset.to_bytes().unwrap(); + + let secret_key = SecretKey::new(client_id, ViturKeyMaterial::from(bytes.clone())); + + (secret_key, client_id, bytes) + } + + mod secret_key_provider { + use super::*; + + #[tokio::test] + async fn returns_client_key_with_matching_id() { + let (secret_key, client_id, _) = random_secret_key(); + + let result = secret_key.client_key().await.unwrap(); + + assert_eq!( + result.key_id, client_id, + "client_key should preserve the client_id" + ); + } + + #[tokio::test] + async fn returns_client_key_with_correct_key_material() { + let (secret_key, _, bytes) = random_secret_key(); + + let result = secret_key.client_key().await.unwrap(); + + let expected_hex = base16ct::lower::encode_string(&bytes); + let actual_hex = result.to_hex_v1().unwrap(); + assert_eq!( + actual_hex, expected_hex, + "client_key should preserve the key material" + ); + } + + #[tokio::test] + async fn returns_invalid_key_for_bad_material() { + let secret_key = + SecretKey::new(Uuid::new_v4(), ViturKeyMaterial::from(vec![0xDE, 0xAD])); + + let err = secret_key.client_key().await.unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey for garbage bytes, got: {err:?}" + ); + } + } + + mod from_hex { + use super::*; + + #[tokio::test] + async fn round_trips_through_key_provider() { + let (original, client_id, bytes) = random_secret_key(); + let hex = base16ct::lower::encode_string(&bytes); + + let from_hex = SecretKey::from_hex(client_id.to_string(), hex).unwrap(); + + let original_key = original.client_key().await.unwrap(); + let from_hex_key = from_hex.client_key().await.unwrap(); + + assert_eq!( + original_key.key_id, from_hex_key.key_id, + "from_hex should produce the same client_id" + ); + assert_eq!( + original_key.to_hex_v1().unwrap(), + from_hex_key.to_hex_v1().unwrap(), + "from_hex should produce the same key material" + ); + } + + #[test] + fn returns_invalid_key_for_bad_uuid() { + let err = SecretKey::from_hex("not-a-uuid".into(), "deadbeef".into()).unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey for bad UUID, got: {err:?}" + ); + } + + #[test] + fn returns_invalid_key_for_bad_encoding() { + let uuid = Uuid::new_v4(); + // `!!` is rejected by both hex and base64 decoders + let err = + SecretKey::from_hex(uuid.to_string(), "not-valid-anything!!".into()).unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey for bad encoding, got: {err:?}" + ); + } + + #[tokio::test] + async fn accepts_base64_encoded_key_material() { + use base64ct::Encoding; + let (_, client_id, bytes) = random_secret_key(); + let b64 = base64ct::Base64::encode_string(&bytes); + + let from_b64 = SecretKey::from_hex(client_id.to_string(), b64).unwrap(); + let key = from_b64.client_key().await.unwrap(); + + let expected_hex = base16ct::lower::encode_string(&bytes); + assert_eq!( + key.to_hex_v1().unwrap(), + expected_hex, + "base64-encoded key material should round-trip to the same bytes as hex" + ); + } + } + + mod from_env { + use super::*; + use crate::test_env::ScopedEnv; + use crate::vars::{CS_CLIENT_ID, CS_CLIENT_KEY}; + + #[test] + fn both_set_and_valid_returns_some() { + let (_, id, bytes) = random_secret_key(); + let id = id.to_string(); + let hex = base16ct::lower::encode_string(&bytes); + let _env = ScopedEnv::new(&[(CS_CLIENT_ID, Some(&id)), (CS_CLIENT_KEY, Some(&hex))]); + + let key = SecretKey::from_env().unwrap().expect("both variables set"); + + assert_eq!(key.client_id.to_string(), id); + assert_eq!(&*key.client_key, bytes.as_slice()); + } + + #[test] + fn only_id_set_returns_none() { + let id = Uuid::new_v4().to_string(); + let _env = ScopedEnv::new(&[(CS_CLIENT_ID, Some(&id)), (CS_CLIENT_KEY, None)]); + + assert!(SecretKey::from_env().unwrap().is_none()); + } + + #[test] + fn only_key_set_returns_none() { + let _env = ScopedEnv::new(&[(CS_CLIENT_ID, None), (CS_CLIENT_KEY, Some("deadbeef"))]); + + assert!(SecretKey::from_env().unwrap().is_none()); + } + + #[test] + fn neither_set_returns_none() { + let _env = ScopedEnv::new(&[(CS_CLIENT_ID, None), (CS_CLIENT_KEY, None)]); + + assert!(SecretKey::from_env().unwrap().is_none()); + } + + #[test] + fn both_set_but_invalid_returns_err() { + let _env = ScopedEnv::new(&[ + (CS_CLIENT_ID, Some("not-a-uuid")), + (CS_CLIENT_KEY, Some("deadbeef")), + ]); + + let err = SecretKey::from_env().unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey, got: {err:?}" + ); + } + } + + #[cfg(all(feature = "profile", not(target_arch = "wasm32")))] + mod profile_store_provider { + use super::*; + use tempfile::TempDir; + + const TEST_WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY"; + + #[tokio::test] + async fn loads_secret_key_from_disk() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + store.init_workspace(TEST_WORKSPACE_ID).unwrap(); + + let (secret_key, client_id, bytes) = random_secret_key(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store.save_profile(&secret_key).unwrap(); + + let result = store.client_key().await.unwrap(); + + assert_eq!( + result.key_id, client_id, + "should load and convert the stored SecretKey" + ); + + let expected_hex = base16ct::lower::encode_string(&bytes); + let actual_hex = result.to_hex_v1().unwrap(); + assert_eq!( + actual_hex, expected_hex, + "should preserve the key material after round-tripping through disk" + ); + } + + #[tokio::test] + async fn returns_not_configured_when_no_workspace_set() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.client_key().await.unwrap_err(); + + assert!( + matches!(err, KeyProviderError::NotConfigured(_)), + "expected NotConfigured when no workspace is set, got: {err:?}" + ); + } + + #[tokio::test] + async fn returns_not_configured_when_file_missing() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + store.init_workspace(TEST_WORKSPACE_ID).unwrap(); + + let err = store.client_key().await.unwrap_err(); + + assert!( + matches!(err, KeyProviderError::NotConfigured(_)), + "expected NotConfigured for missing file, got: {err:?}" + ); + + let msg = err.to_string(); + assert!( + msg.contains("Profile not found"), + "error should explain the profile is missing, got: {msg}" + ); + } + + #[tokio::test] + async fn returns_load_error_for_invalid_json() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + store.init_workspace(TEST_WORKSPACE_ID).unwrap(); + + // Write invalid JSON to the workspace-scoped file path + let ws_dir = dir.path().join("workspaces").join(TEST_WORKSPACE_ID); + std::fs::create_dir_all(&ws_dir).unwrap(); + std::fs::write(ws_dir.join(SecretKey::FILENAME), "not json").unwrap(); + + let err = store.client_key().await.unwrap_err(); + + assert!( + matches!(err, KeyProviderError::LoadError(_)), + "expected LoadError for corrupt file, got: {err:?}" + ); + + let msg = err.to_string(); + assert!( + msg.contains("JSON error"), + "error should mention the JSON parse failure, got: {msg}" + ); + } + } +} diff --git a/packages/stack-kms/src/user_agent.rs b/packages/stack-kms/src/user_agent.rs new file mode 100644 index 000000000..e48da926e --- /dev/null +++ b/packages/stack-kms/src/user_agent.rs @@ -0,0 +1,16 @@ +use lazy_static::lazy_static; +use std::env::consts::{ARCH, OS}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const SECONDARY_AGENT: Option<&str> = option_env!("CIPHERSTASH_CLIENT_SECONDARY_USER_AGENT"); + +pub(crate) fn get_user_agent() -> &'static str { + lazy_static! { + static ref USER_AGENT: String = format!( + "stack-kms/{VERSION} ({OS} {ARCH}{})", + SECONDARY_AGENT.map(|x| format!(" {x}")).unwrap_or_default() + ); + } + + &USER_AGENT +} diff --git a/packages/stack-kms/src/vars.rs b/packages/stack-kms/src/vars.rs new file mode 100644 index 000000000..60766c75f --- /dev/null +++ b/packages/stack-kms/src/vars.rs @@ -0,0 +1,14 @@ +//! Environment variable names recognised by `stack-kms`. +//! +//! These mirror the names used by `cipherstash-client` so the two crates stay +//! interchangeable for credential discovery. + +/// Endpoint override for the ZeroKMS service. The first present variable wins; +/// `CS_VITUR_HOST` is the legacy name kept for backwards compatibility. +pub static CS_ZEROKMS_HOST: &[&str] = &["CS_ZEROKMS_HOST", "CS_VITUR_HOST"]; + +/// The client (device) ID used to authenticate key operations. +pub static CS_CLIENT_ID: &str = "CS_CLIENT_ID"; + +/// The client key material (hex or base64 encoded) used to derive data keys. +pub static CS_CLIENT_KEY: &str = "CS_CLIENT_KEY"; diff --git a/packages/stack-kms/tasks.toml b/packages/stack-kms/tasks.toml new file mode 100644 index 000000000..2ea3346fc --- /dev/null +++ b/packages/stack-kms/tasks.toml @@ -0,0 +1,27 @@ +# Fuzz stack-kms's public client-key material decoder +# (`ClientKey::from_encoded_v1`, hex or base64 then CBOR keyset decoding), via +# libFuzzer/cargo-fuzz. Requires the nightly toolchain. Runs 60s by default; +# override by appending a libFuzzer flag, e.g. +# `mise run fuzz:client-key -- -max_total_time=300` (the last repeated value +# wins). `--sanitizer none` is safe — the decode path is pure safe Rust. +# `--target $(rustc … host)` forces the native host triple (the cargo-fuzz +# binary may be x86_64 under Rosetta on Apple Silicon, which otherwise +# misdetects the target). The fuzz crate lives in `packages/stack-kms/fuzz/` +# (detached). +["fuzz:client-key"] +description = "Fuzz stack-kms's client-key material decoder (libFuzzer, nightly, 60s default)" +dir = "{{config_root}}/packages/stack-kms" +run = "mise x --env test -- cargo +nightly fuzz run client_key_encoded --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" + +# Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc +# warning fails the build. Doc *examples* are `test:doc:stack-kms`. Both run +# with all features so nothing feature-gated goes unchecked; the root `doc` +# task fans out over every `doc:<crate>`. +["doc:stack-kms"] +description = "Build docs for stack-kms with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-kms --no-deps --all-features" + +["test:doc:stack-kms"] +description = "Run documentation tests for stack-kms" +run = "mise x --env test -- cargo test -p stack-kms --doc --all-features" diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md new file mode 100644 index 000000000..74c659b65 --- /dev/null +++ b/packages/stack-profile/CHANGELOG.md @@ -0,0 +1,64 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + + + + + + + + + + + + + + + + +### Fixes + +- make ProfileStore writes atomic via tmp file + rename +- serialise refresh-token rotation across processes +- address Copilot review on Windows + crash durability + + + + + +### Documentation + +- fix stale 0.34.0-alpha.1 changelog headers + + + +## [0.42.3] - 2026-08-26 + + +## [0.42.2] - 2026-08-17 + + +## [0.42.1] - 2026-08-12 + + +## [0.42.0] - 2026-07-19 + + +## [0.41.1] - 2026-07-17 + + +## [0.41.0] - 2026-07-17 + + +## [0.40.0] - 2026-07-09 + + +## [0.34.0] - 2026-03-04 + +### Changed +- Consolidated all publishable crate versions to 0.34.0; version is now centralized via `workspace.package.version` diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml new file mode 100644 index 000000000..93f11306b --- /dev/null +++ b/packages/stack-profile/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "stack-profile" +description = "Centralised ~/.cipherstash profile file management" +license-file = "LICENSE" +version = "0.42.3" +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +dirs = "4.0.0" +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } + +# The default device name is the hostname, which only the creating half of +# `DeviceIdentity` needs — and that half is native-only: the crate builds for +# wasm32-wasip1 as the Go binding's credential guest, which reads an identity +# the CLI created and never creates one (gethostname has no wasip1 body). +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +gethostname = "0.5" + +[dev-dependencies] +tempfile = "3.21.0" diff --git a/packages/stack-profile/LICENSE b/packages/stack-profile/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-profile/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + +<https://polyformproject.org/licenses/internal-use/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-profile/src/device_identity.rs b/packages/stack-profile/src/device_identity.rs new file mode 100644 index 000000000..fef7d53f9 --- /dev/null +++ b/packages/stack-profile/src/device_identity.rs @@ -0,0 +1,110 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ProfileData, ProfileError, ProfileStore}; + +/// Persistent identity for a CLI installation. +/// +/// Each device gets a unique `device_instance_id` (UUIDv4) and a human-readable +/// `device_name` (defaults to the hostname). The identity is stored in +/// `~/.cipherstash/device.json` and reused across sessions so the server can +/// track device lifecycle. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceIdentity { + /// A UUIDv4 that uniquely identifies this CLI installation. + pub device_instance_id: Uuid, + /// A human-readable name for this device (defaults to the hostname). + pub device_name: String, +} + +impl ProfileData for DeviceIdentity { + const FILENAME: &'static str = "device.json"; + const MODE: Option<u32> = Some(0o600); +} + +impl DeviceIdentity { + /// Load an existing device identity from the given store, or create a new + /// one if none exists. + /// + /// When creating, generates a UUIDv4 and uses the system hostname as the + /// default device name. The file is written with mode 0600 on Unix. + /// + /// Native targets only. Creating an identity is what the CLI does when it + /// provisions a client at login; a wasm32 build of this crate (the Go + /// binding's credential guest) reads the identity the CLI wrote and must + /// not be able to mint one — it has no hostname to name it after, and a + /// made-up name would be worse than none. Use [`DeviceIdentity::load`] + /// there. + #[cfg(not(target_arch = "wasm32"))] + pub fn load_or_create(store: &ProfileStore) -> Result<Self, ProfileError> { + match store.load_profile::<Self>() { + Ok(identity) => Ok(identity), + Err(ProfileError::NotFound { .. }) => { + let identity = Self { + device_instance_id: Uuid::new_v4(), + device_name: gethostname::gethostname().to_string_lossy().into_owned(), + }; + store.save_profile(&identity)?; + Ok(identity) + } + Err(e) => Err(e), + } + } + + /// Load a device identity from the given store. + /// + /// Returns [`ProfileError::NotFound`] if the file does not exist. + pub fn load(store: &ProfileStore) -> Result<Self, ProfileError> { + store.load_profile() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_or_create_generates_new_identity() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let identity = DeviceIdentity::load_or_create(&store).unwrap(); + assert!(!identity.device_instance_id.is_nil()); + assert!(!identity.device_name.is_empty()); + } + + #[test] + fn load_or_create_reuses_existing() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let first = DeviceIdentity::load_or_create(&store).unwrap(); + let second = DeviceIdentity::load_or_create(&store).unwrap(); + assert_eq!(first.device_instance_id, second.device_instance_id); + assert_eq!(first.device_name, second.device_name); + } + + #[test] + fn load_returns_not_found_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + let err = DeviceIdentity::load(&store).unwrap_err(); + assert!(matches!(err, ProfileError::NotFound { .. })); + } + + #[test] + fn round_trip_serialization() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let original = DeviceIdentity { + device_instance_id: Uuid::new_v4(), + device_name: "test-host".to_string(), + }; + store.save("device.json", &original).unwrap(); + + let loaded = DeviceIdentity::load(&store).unwrap(); + assert_eq!(original.device_instance_id, loaded.device_instance_id); + assert_eq!(original.device_name, loaded.device_name); + } +} diff --git a/packages/stack-profile/src/error.rs b/packages/stack-profile/src/error.rs new file mode 100644 index 000000000..bb0cad253 --- /dev/null +++ b/packages/stack-profile/src/error.rs @@ -0,0 +1,34 @@ +use std::path::PathBuf; + +/// Errors that can occur when reading or writing profile files. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ProfileError { + /// An I/O error occurred while reading or writing a profile file. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + /// A profile file contained invalid JSON. + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + /// The user's home directory could not be determined. + #[error("Could not determine home directory")] + HomeDirNotFound, + /// The requested profile file was not found. + #[error("Profile not found: {path}")] + NotFound { + /// The path that was looked up. + path: PathBuf, + }, + /// The filename is invalid (contains path separators, `..`, or is absolute). + #[error("Invalid profile filename: {0}")] + InvalidFilename(String), + /// No current workspace is set but a workspace-scoped operation was attempted. + #[error("No current workspace set. Run `stash login` or `stash workspaces switch` first.")] + NoCurrentWorkspace, + /// The workspace ID is invalid (not a 16-character base32 string). + #[error("Invalid workspace ID: {0}")] + InvalidWorkspaceId(String), + /// The workspace has no local profile data (not logged in). + #[error("Workspace not found: {0}. Log in to this workspace first.")] + WorkspaceNotFound(String), +} diff --git a/packages/stack-profile/src/lib.rs b/packages/stack-profile/src/lib.rs new file mode 100644 index 000000000..2dc30841b --- /dev/null +++ b/packages/stack-profile/src/lib.rs @@ -0,0 +1,81 @@ +// Security lints +#![deny(unsafe_code)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +// Prevent mem::forget from bypassing ZeroizeOnDrop +#![warn(clippy::mem_forget)] +// Prevent accidental data leaks via output +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Relax in tests +#![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))] + +//! Centralised `~/.cipherstash/` profile file management. +//! +//! The core type is [`ProfileStore`], a directory-scoped JSON file store that +//! handles reading, writing, and deleting profile data on disk. +//! +//! # Example +//! +//! ```no_run +//! use stack_profile::ProfileStore; +//! use serde::{Serialize, Deserialize}; +//! +//! #[derive(Serialize, Deserialize)] +//! struct MyConfig { +//! name: String, +//! } +//! +//! # fn main() -> Result<(), stack_profile::ProfileError> { +//! let store = ProfileStore::default(); +//! +//! store.save("my-config.json", &MyConfig { name: "example".into() })?; +//! let config: MyConfig = store.load("my-config.json")?; +//! # Ok(()) +//! # } +//! ``` +//! +//! For sensitive files, use [`ProfileStore::save_with_mode`] to restrict permissions: +//! +//! ```no_run +//! # use stack_profile::ProfileStore; +//! # use serde::{Serialize, Deserialize}; +//! # #[derive(Serialize, Deserialize)] +//! # struct Secret { key: String } +//! # fn main() -> Result<(), stack_profile::ProfileError> { +//! let store = ProfileStore::default(); +//! store.save_with_mode("secret.json", &Secret { key: "shhh".into() }, 0o600)?; +//! # Ok(()) +//! # } +//! ``` + +use serde::de::DeserializeOwned; +use serde::Serialize; + +mod device_identity; +mod error; +mod profile_store; + +pub use device_identity::DeviceIdentity; +pub use error::ProfileError; +pub use profile_store::{FileLockGuard, ProfileStore}; + +/// A type that can be stored in a profile directory. +pub trait ProfileData: Serialize + DeserializeOwned { + /// The filename used when saving/loading this type (e.g. `"secretkey.json"`). + const FILENAME: &'static str; + + /// Unix file permissions for this file. `None` uses the default umask. + /// Sensitive files should return `Some(0o600)`. + const MODE: Option<u32> = None; +} diff --git a/packages/stack-profile/src/profile_store.rs b/packages/stack-profile/src/profile_store.rs new file mode 100644 index 000000000..f32b47be9 --- /dev/null +++ b/packages/stack-profile/src/profile_store.rs @@ -0,0 +1,1422 @@ +use std::path::{Path, PathBuf}; + +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::{ProfileData, ProfileError}; + +const CS_CONFIG_PATH_ENV: &str = "CS_CONFIG_PATH"; +const DEFAULT_DIR_NAME: &str = ".cipherstash"; +const WORKSPACES_DIR: &str = "workspaces"; +const CURRENT_WORKSPACE_FILE: &str = "current_workspace"; + +/// A directory-scoped JSON file store for profile data. +/// +/// `ProfileStore` represents a profile directory (typically `~/.cipherstash/`). +/// Individual files are addressed by name when calling [`save`](Self::save), +/// [`load`](Self::load), and other operations. +/// +/// # Example +/// +/// ```no_run +/// use stack_profile::ProfileStore; +/// use serde::{Serialize, Deserialize}; +/// +/// #[derive(Serialize, Deserialize)] +/// struct MyConfig { +/// name: String, +/// } +/// +/// # fn main() -> Result<(), stack_profile::ProfileError> { +/// let store = ProfileStore::resolve(None)?; +/// store.save("my-config.json", &MyConfig { name: "example".into() })?; +/// let config: MyConfig = store.load("my-config.json")?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone)] +pub struct ProfileStore { + dir: PathBuf, +} + +/// RAII guard for an advisory file lock acquired via +/// [`ProfileStore::lock_exclusive`]. Releases on drop. +/// +/// The guard owns the lock file handle; dropping it calls `unlock` and +/// closes the descriptor. The lock file itself is left on disk — it's reused +/// across acquisitions and carries no useful content. +#[must_use = "the lock is released as soon as this guard is dropped"] +#[derive(Debug)] +pub struct FileLockGuard { + file: std::fs::File, +} + +impl Drop for FileLockGuard { + fn drop(&mut self) { + // Best-effort — the kernel releases on close regardless, so a failure + // here only matters for diagnostics. Don't log: this runs during + // teardown and the file may already be invalid (e.g. on process exit). + let _ = self.file.unlock(); + } +} + +impl ProfileStore { + /// Create a profile store rooted at the given directory. + pub fn new(dir: impl Into<PathBuf>) -> Self { + Self { dir: dir.into() } + } + + /// Resolve the profile directory. + /// + /// Resolution order: + /// 1. `explicit` path, if provided + /// 2. `CS_CONFIG_PATH` environment variable, if set + /// 3. `~/.cipherstash` (the default) + pub fn resolve(explicit: Option<PathBuf>) -> Result<Self, ProfileError> { + if let Some(path) = explicit { + return Ok(Self::new(path)); + } + if let Ok(path) = std::env::var(CS_CONFIG_PATH_ENV) { + if !path.trim().is_empty() { + return Ok(Self::new(path)); + } + } + let home = dirs::home_dir().ok_or(ProfileError::HomeDirNotFound)?; + Ok(Self::new(home.join(DEFAULT_DIR_NAME))) + } + + /// Return the directory path. + pub fn dir(&self) -> &Path { + &self.dir + } + + /// Save a value as pretty-printed JSON to a file in the store directory. + /// + /// Creates the directory and any parents if they don't exist. + pub fn save<T: Serialize>(&self, filename: &str, value: &T) -> Result<(), ProfileError> { + self.write(filename, value, None) + } + + /// Save a value as pretty-printed JSON with restricted Unix file permissions. + /// + /// On non-Unix platforms the mode is ignored and this behaves like [`save`](Self::save). + pub fn save_with_mode<T: Serialize>( + &self, + filename: &str, + value: &T, + _mode: u32, + ) -> Result<(), ProfileError> { + #[cfg(unix)] + return self.write(filename, value, Some(_mode)); + #[cfg(not(unix))] + self.write(filename, value, None) + } + + /// Validate that a filename is a plain, non-empty filename (no path separators or `..`). + fn validate_filename(filename: &str) -> Result<(), ProfileError> { + let path = Path::new(filename); + if filename.is_empty() + || path.is_absolute() + || filename.contains(std::path::MAIN_SEPARATOR) + || filename.contains('/') + || path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(ProfileError::InvalidFilename(filename.to_string())); + } + Ok(()) + } + + /// Validate that a workspace ID is a 16-character base32 string (A-Z, 2-7). + /// + /// This prevents path traversal without depending on `cts_common::WorkspaceId`. + fn validate_workspace_id(id: &str) -> Result<(), ProfileError> { + let valid = id.len() == 16 + && id + .bytes() + .all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)); + if valid { + Ok(()) + } else { + Err(ProfileError::InvalidWorkspaceId(id.to_string())) + } + } + + // ---- Workspace management ---- + + /// Set the current workspace. + /// + /// Writes the workspace ID to the `current_workspace` file in the profile + /// directory. The workspace must already have a directory under `workspaces/` + /// (created during login). Use [`init_workspace`](Self::init_workspace) to + /// create a new workspace directory. + /// + /// Returns [`ProfileError::WorkspaceNotFound`] if the workspace directory + /// does not exist. + pub fn set_current_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> { + Self::validate_workspace_id(workspace_id)?; + let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id); + if !ws_dir.is_dir() { + return Err(ProfileError::WorkspaceNotFound(workspace_id.to_string())); + } + std::fs::create_dir_all(&self.dir)?; + let path = self.dir.join(CURRENT_WORKSPACE_FILE); + std::fs::write(&path, workspace_id)?; + Ok(()) + } + + /// Create a workspace directory and set it as the current workspace. + /// + /// Unlike [`set_current_workspace`](Self::set_current_workspace), this + /// creates the workspace directory if it does not exist. Used during login + /// to initialize a new workspace. + pub fn init_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> { + Self::validate_workspace_id(workspace_id)?; + // create_dir_all creates self.dir and workspaces/ as ancestors. + let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id); + std::fs::create_dir_all(&ws_dir)?; + let path = self.dir.join(CURRENT_WORKSPACE_FILE); + std::fs::write(&path, workspace_id)?; + Ok(()) + } + + /// Return the current workspace ID. + /// + /// Returns [`ProfileError::NoCurrentWorkspace`] if no workspace has been set. + pub fn current_workspace(&self) -> Result<String, ProfileError> { + let path = self.dir.join(CURRENT_WORKSPACE_FILE); + match std::fs::read_to_string(&path) { + Ok(contents) => { + let id = contents.trim().to_string(); + Self::validate_workspace_id(&id)?; + Ok(id) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + Err(ProfileError::NoCurrentWorkspace) + } + Err(e) => Err(ProfileError::Io(e)), + } + } + + /// Remove the current workspace selection. + pub fn clear_current_workspace(&self) -> Result<(), ProfileError> { + let path = self.dir.join(CURRENT_WORKSPACE_FILE); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(ProfileError::Io(e)), + } + } + + /// List workspace IDs that have profile data on disk. + /// + /// Returns a sorted list of workspace IDs that have subdirectories in + /// the `workspaces/` directory. + pub fn list_workspaces(&self) -> Result<Vec<String>, ProfileError> { + let ws_dir = self.dir.join(WORKSPACES_DIR); + match std::fs::read_dir(&ws_dir) { + Ok(entries) => { + let mut ids = Vec::new(); + for entry in entries { + let entry = entry?; + if entry.file_type()?.is_dir() { + if let Some(name) = entry.file_name().to_str() { + if Self::validate_workspace_id(name).is_ok() { + ids.push(name.to_string()); + } + } + } + } + ids.sort(); + Ok(ids) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(e) => Err(ProfileError::Io(e)), + } + } + + /// Return a [`ProfileStore`] scoped to a specific workspace directory. + /// + /// The returned store is rooted at `workspaces/<workspace_id>/` within this + /// store's directory. All `save`/`load`/`save_profile`/`load_profile` calls + /// on the returned store operate inside that workspace directory. + pub fn workspace_store(&self, workspace_id: &str) -> Result<ProfileStore, ProfileError> { + Self::validate_workspace_id(workspace_id)?; + Ok(ProfileStore::new( + self.dir.join(WORKSPACES_DIR).join(workspace_id), + )) + } + + /// Return a [`ProfileStore`] scoped to the current workspace. + /// + /// Shortcut for `store.workspace_store(&store.current_workspace()?)`. + /// Returns [`ProfileError::NoCurrentWorkspace`] if no workspace has been set. + pub fn current_workspace_store(&self) -> Result<ProfileStore, ProfileError> { + let id = self.current_workspace()?; + self.workspace_store(&id) + } + + /// Move legacy flat-file profiles into a workspace directory. + /// + /// Moves `auth.json` and `secretkey.json` from the profile root into + /// `workspaces/<workspace_id>/` and sets `workspace_id` as the current + /// workspace. Files that already exist in the target are not overwritten. + /// Missing source files are silently skipped. + pub fn migrate_to_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> { + Self::validate_workspace_id(workspace_id)?; + let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id); + std::fs::create_dir_all(&ws_dir)?; + + for filename in &["auth.json", "secretkey.json"] { + let src = self.dir.join(filename); + let dst = ws_dir.join(filename); + if src.exists() && !dst.exists() { + std::fs::rename(&src, &dst)?; + } + } + + self.set_current_workspace(workspace_id)?; + Ok(()) + } + + // ---- Internal write helpers ---- + + fn write<T: Serialize>( + &self, + filename: &str, + value: &T, + _mode: Option<u32>, + ) -> Result<(), ProfileError> { + Self::validate_filename(filename)?; + std::fs::create_dir_all(&self.dir)?; + let path = self.dir.join(filename); + let json = serde_json::to_string_pretty(value)?; + Self::write_to_path(&path, &json, _mode) + } + + /// Atomically write JSON content to an absolute path, optionally setting + /// Unix file permissions. + /// + /// Sequence: + /// 1. Open a uniquely-named sibling tmp file in the same directory. + /// 2. Write the bytes and `sync_all` (fsync the data + metadata). + /// 3. Apply the requested mode with `set_permissions` (overrides + /// umask). + /// 4. Rename the tmp file over the target. `std::fs::rename` uses + /// `MOVEFILE_REPLACE_EXISTING` on Windows and the POSIX `rename` + /// on Unix, so the replacement is atomic on both platforms. + /// 5. On Unix, fsync the parent directory so the rename is durable + /// across power loss. Windows doesn't expose directory fsync, so + /// this step is Unix-only — Windows callers get atomicity but + /// slightly weaker crash-durability guarantees. + /// + /// Two concurrent writers cannot produce torn reads, and a crash + /// mid-write leaves either the prior file intact or no destination + /// file at all. The tmp file name embeds the process ID + a UUID so + /// concurrent writers (across processes or threads) don't collide on + /// the staging path. + /// + /// On Windows the rename can transiently fail with + /// `ERROR_SHARING_VIOLATION` when an external (non-Rust) process holds + /// the target open without `FILE_SHARE_DELETE`. Rust's own + /// `File::open` sets that share flag, so contention between two Rust + /// processes won't trip this — but to defend against third-party + /// readers we retry the rename a handful of times with brief backoff + /// before giving up. + fn write_to_path(path: &Path, json: &str, _mode: Option<u32>) -> Result<(), ProfileError> { + use std::io::Write; + + let parent = path.parent().ok_or_else(|| { + ProfileError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "target path has no parent directory", + )) + })?; + let file_name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| { + ProfileError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "target path has no file name", + )) + })?; + // The process id keeps two processes' staging files apart; the UUID + // keeps two threads' apart. On wasm32-wasip1 `std::process::id()` + // aborts the module ("unsupported"), and a wasm instance is the only + // process there is, so the UUID alone carries the uniqueness. + #[cfg(not(target_arch = "wasm32"))] + let pid = std::process::id(); + #[cfg(target_arch = "wasm32")] + let pid = 0u32; + let tmp_path = parent.join(format!( + ".{file_name}.tmp.{pid}.{}", + uuid::Uuid::new_v4().simple() + )); + + let result = (|| -> Result<(), ProfileError> { + let mut file = { + let mut opts = std::fs::OpenOptions::new(); + let _ = opts.write(true).create_new(true); + #[cfg(unix)] + if let Some(mode) = _mode { + use std::os::unix::fs::OpenOptionsExt; + let _ = opts.mode(mode); + } + opts.open(&tmp_path)? + }; + file.write_all(json.as_bytes())?; + file.sync_all()?; + drop(file); + + // `OpenOptions::mode()` is masked by the process umask, so an + // explicit `set_permissions` is required to guarantee the exact + // mode the caller asked for. + #[cfg(unix)] + if let Some(mode) = _mode { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(mode))?; + } + + Self::rename_with_retry(&tmp_path, path)?; + + // Durability: fsync the parent directory so the rename itself + // survives a power loss, not just the file contents written + // above. Unix-only because Windows has no directory fsync + // primitive (and its filesystem metadata journaling makes + // this less necessary in practice). + #[cfg(unix)] + { + let dir = std::fs::File::open(parent)?; + dir.sync_all()?; + } + + Ok(()) + })(); + + // On failure, the rename never happened (or was rolled back), so + // clean up the staging file. Best-effort — if cleanup itself + // fails there's nothing useful we can do beyond the original + // error. + if result.is_err() { + let _ = std::fs::remove_file(&tmp_path); + } + + result + } + + /// Rename `from` to `to`, retrying briefly on Windows + /// `ERROR_SHARING_VIOLATION` (a transient failure when an external + /// process holds the target open without `FILE_SHARE_DELETE`). On + /// Unix the first attempt always succeeds or fails for a permanent + /// reason, so the retry loop is a no-op there. + fn rename_with_retry(from: &Path, to: &Path) -> std::io::Result<()> { + // 5 attempts * 20ms = up to 100ms — long enough to ride out a + // typical short-lived external read, short enough not to feel + // hung if the contention is real. + const MAX_ATTEMPTS: u32 = 5; + const BACKOFF: std::time::Duration = std::time::Duration::from_millis(20); + + for attempt in 1..=MAX_ATTEMPTS { + match std::fs::rename(from, to) { + Ok(()) => return Ok(()), + Err(e) if attempt < MAX_ATTEMPTS && Self::is_transient_rename_error(&e) => { + std::thread::sleep(BACKOFF); + } + Err(e) => return Err(e), + } + } + // Unreachable — the loop either returns or breaks via the last + // attempt's `Err` arm above. + unreachable!() + } + + /// True if the error is a sharing/access conflict that's worth + /// retrying. On Unix `rename` doesn't produce these (the equivalent + /// would be `EBUSY` on overlay/network filesystems, but it's rare and + /// usually non-transient), so this is effectively a Windows guard. + #[cfg(windows)] + fn is_transient_rename_error(e: &std::io::Error) -> bool { + // ERROR_SHARING_VIOLATION = 32, ERROR_ACCESS_DENIED = 5. Both can + // appear transiently when MoveFileEx hits a target that's open. + matches!(e.raw_os_error(), Some(32) | Some(5)) + } + + #[cfg(not(windows))] + fn is_transient_rename_error(_e: &std::io::Error) -> bool { + false + } + + /// Load a value from a JSON file in the store directory. + /// + /// Returns [`ProfileError::NotFound`] if the file does not exist. + pub fn load<T: DeserializeOwned>(&self, filename: &str) -> Result<T, ProfileError> { + Self::validate_filename(filename)?; + let path = self.dir.join(filename); + match std::fs::read_to_string(&path) { + Ok(contents) => { + let value: T = serde_json::from_str(&contents)?; + Ok(value) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + Err(ProfileError::NotFound { path }) + } + Err(e) => Err(ProfileError::Io(e)), + } + } + + /// Remove a file from the store directory. + /// + /// Does nothing if the file does not already exist. + pub fn clear(&self, filename: &str) -> Result<(), ProfileError> { + Self::validate_filename(filename)?; + let path = self.dir.join(filename); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(ProfileError::Io(e)), + } + } + + /// Check whether a file exists in the store directory. + pub fn exists(&self, filename: &str) -> bool { + Self::validate_filename(filename).is_ok() && self.dir.join(filename).exists() + } + + /// Acquire an exclusive advisory lock that serialises critical sections + /// against other processes sharing this profile directory. + /// + /// The lock is held on a sibling file (`.<filename>.lock`) so it survives + /// atomic rewrites of the target. This is **blocking** — call it from a + /// `spawn_blocking` task when invoked from async code. Released when the + /// returned [`FileLockGuard`] is dropped. + /// + /// Intended use is around the read-modify-write window for files like + /// `auth.json` where a non-atomic critical section across processes + /// causes silent state corruption (in the auth case: refresh-token + /// rotation replay). + pub fn lock_exclusive(&self, filename: &str) -> Result<FileLockGuard, ProfileError> { + let lock_path = self.lock_path(filename)?; + std::fs::create_dir_all(&self.dir)?; + let file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(&lock_path)?; + file.lock()?; + Ok(FileLockGuard { file }) + } + + /// The path of the lock file [`lock_exclusive`](Self::lock_exclusive) + /// takes for `filename`: a sibling `.<filename>.lock` in this store's + /// directory. Nothing is created or locked. + /// + /// This is for a host that must hold the lock on the crate's behalf. + /// WASI preview 1 has no file locking, so the Go binding's credential + /// guest cannot take it; the Go side takes the same lock on the path + /// this names, and never composes a profile path itself. + pub fn lock_path(&self, filename: &str) -> Result<PathBuf, ProfileError> { + Self::validate_filename(filename)?; + Ok(self.dir.join(format!(".{filename}.lock"))) + } + + /// Save a [`ProfileData`] value using its declared filename and mode. + pub fn save_profile<T: ProfileData>(&self, value: &T) -> Result<(), ProfileError> { + self.write(T::FILENAME, value, T::MODE) + } + + /// Load a [`ProfileData`] value from its declared filename. + pub fn load_profile<T: ProfileData>(&self) -> Result<T, ProfileError> { + self.load(T::FILENAME) + } + + /// Remove the file for a [`ProfileData`] type. + pub fn clear_profile<T: ProfileData>(&self) -> Result<(), ProfileError> { + self.clear(T::FILENAME) + } + + /// Check whether the file for a [`ProfileData`] type exists. + pub fn exists_profile<T: ProfileData>(&self) -> bool { + self.exists(T::FILENAME) + } +} + +/// Returns a profile store at `~/.cipherstash`. +/// +/// # Panics +/// +/// Panics if the home directory cannot be determined. +impl Default for ProfileStore { + #[allow(clippy::expect_used)] + fn default() -> Self { + let home = dirs::home_dir().expect("could not determine home directory"); + Self::new(home.join(DEFAULT_DIR_NAME)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct TestData { + name: String, + value: u32, + } + + mod lock_path { + use super::*; + + #[test] + fn names_the_file_lock_exclusive_takes() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let path = store.lock_path("auth.json").unwrap(); + assert_eq!(path, dir.path().join(".auth.json.lock")); + assert!(!path.exists(), "naming the lock file must not create it"); + + let _guard = store.lock_exclusive("auth.json").unwrap(); + assert!( + path.exists(), + "lock_exclusive locks the file lock_path names" + ); + } + + #[test] + fn rejects_an_invalid_filename() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + for bad in ["", "../auth.json", "/etc/auth.json"] { + let err = store.lock_path(bad).unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidFilename(_)), + "{bad:?}: {err}" + ); + } + } + } + + #[test] + fn round_trip_save_and_load() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let data = TestData { + name: "hello".into(), + value: 42, + }; + store.save("data.json", &data).unwrap(); + + let loaded: TestData = store.load("data.json").unwrap(); + assert_eq!(loaded, data); + } + + #[test] + fn load_returns_not_found_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.load::<TestData>("missing.json").unwrap_err(); + assert!(matches!(err, ProfileError::NotFound { .. })); + } + + #[test] + fn clear_removes_existing_file() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + store + .save( + "data.json", + &TestData { + name: "x".into(), + value: 1, + }, + ) + .unwrap(); + assert!(store.exists("data.json")); + + store.clear("data.json").unwrap(); + assert!(!store.exists("data.json")); + } + + #[test] + fn clear_succeeds_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + store.clear("missing.json").unwrap(); + } + + #[test] + fn save_creates_directory() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path().join("nested").join("dir")); + + store + .save( + "data.json", + &TestData { + name: "nested".into(), + value: 99, + }, + ) + .unwrap(); + + let loaded: TestData = store.load("data.json").unwrap(); + assert_eq!(loaded.name, "nested"); + } + + #[test] + fn exists_returns_false_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + assert!(!store.exists("missing.json")); + } + + #[test] + fn default_is_home_dot_cipherstash() { + let store = ProfileStore::default(); + let home = dirs::home_dir().unwrap(); + assert_eq!(store.dir(), home.join(".cipherstash")); + } + + #[test] + fn resolve_explicit_overrides_all() { + let store = ProfileStore::resolve(Some("/tmp/custom".into())).unwrap(); + assert_eq!(store.dir(), std::path::Path::new("/tmp/custom")); + } + + mod filename_validation { + use super::*; + + #[test] + fn rejects_empty_string() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store + .save( + "", + &TestData { + name: "x".into(), + value: 1, + }, + ) + .unwrap_err(); + assert!(matches!(err, ProfileError::InvalidFilename(_))); + } + + #[test] + fn rejects_absolute_path() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store + .save( + "/etc/passwd", + &TestData { + name: "x".into(), + value: 1, + }, + ) + .unwrap_err(); + assert!(matches!(err, ProfileError::InvalidFilename(_))); + } + + #[test] + fn rejects_parent_traversal() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store + .save( + "../escape.json", + &TestData { + name: "x".into(), + value: 1, + }, + ) + .unwrap_err(); + assert!(matches!(err, ProfileError::InvalidFilename(_))); + } + + #[test] + fn rejects_path_with_separator() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store + .save( + "sub/file.json", + &TestData { + name: "x".into(), + value: 1, + }, + ) + .unwrap_err(); + assert!(matches!(err, ProfileError::InvalidFilename(_))); + } + + #[test] + fn rejects_on_load() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.load::<TestData>("../escape.json").unwrap_err(); + assert!(matches!(err, ProfileError::InvalidFilename(_))); + } + + #[test] + fn rejects_on_clear() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.clear("../escape.json").unwrap_err(); + assert!(matches!(err, ProfileError::InvalidFilename(_))); + } + + #[test] + fn exists_returns_false_for_invalid_filename() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + assert!(!store.exists("../escape.json")); + } + + #[test] + fn accepts_plain_filename() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + store + .save( + "valid.json", + &TestData { + name: "ok".into(), + value: 1, + }, + ) + .unwrap(); + let loaded: TestData = store.load("valid.json").unwrap(); + assert_eq!(loaded.name, "ok"); + } + } + + #[cfg(unix)] + #[test] + fn save_with_mode_sets_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + store + .save_with_mode( + "secret.json", + &TestData { + name: "secret".into(), + value: 1, + }, + 0o600, + ) + .unwrap(); + + let meta = std::fs::metadata(dir.path().join("secret.json")).unwrap(); + let mode = meta.permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + + #[cfg(unix)] + #[test] + fn save_with_mode_tightens_existing_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + let path = dir.path().join("secret.json"); + + // Create file with broad permissions first + store + .save( + "secret.json", + &TestData { + name: "v1".into(), + value: 1, + }, + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + // Overwrite with restricted mode + store + .save_with_mode( + "secret.json", + &TestData { + name: "v2".into(), + value: 2, + }, + 0o600, + ) + .unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "permissions should be tightened on existing file" + ); + } + + /// Concurrent writers must never expose torn content to a reader. Each + /// write goes through a sibling tmp file + rename, so an interleaved + /// reader sees either the prior complete file or a complete new file — + /// never a half-written one. + #[test] + fn concurrent_writes_never_expose_torn_content() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + use std::thread; + + #[derive(serde::Serialize, serde::Deserialize)] + struct Big { + // Large payload so any non-atomic write would leave an + // observably-incomplete file mid-flight. + payload: String, + writer: usize, + } + + fn make_value(writer: usize, payload_size: usize) -> Big { + Big { + // Encode the writer ID into the payload so any torn + // mix-and-match between writers would show up as an + // unparseable / inconsistent file. + payload: char::from_digit(writer as u32, 16) + .unwrap() + .to_string() + .repeat(payload_size), + writer, + } + } + + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(ProfileStore::new(dir.path())); + let writers = 8; + let iterations = 50; + // 64 KiB per write — well above any sane page/buffer size. + let payload_size = 64 * 1024; + + // Pre-seed so the reader always has a file to observe, even before + // any concurrent writer completes its first save. + store + .save("contended.json", &make_value(0, payload_size)) + .unwrap(); + + let done = Arc::new(AtomicBool::new(false)); + + let mut handles = Vec::with_capacity(writers); + for writer in 0..writers { + let store = Arc::clone(&store); + handles.push(thread::spawn(move || { + for _ in 0..iterations { + store + .save("contended.json", &make_value(writer, payload_size)) + .unwrap(); + } + })); + } + + // Race reads against the writers. Every successful read must yield a + // well-formed JSON whose payload matches the declared writer — proving + // we never observed a partial overwrite. + let reader_store = Arc::clone(&store); + let reader_done = Arc::clone(&done); + let reader = thread::spawn(move || { + let mut reads = 0; + while !reader_done.load(Ordering::Relaxed) { + match reader_store.load::<Big>("contended.json") { + Ok(value) => { + let expected_char = char::from_digit(value.writer as u32, 16) + .unwrap() + .to_string(); + assert_eq!( + value.payload.len(), + payload_size, + "torn write — payload truncated" + ); + assert!( + value + .payload + .chars() + .all(|c| c.to_string() == expected_char), + "torn write — writer {} payload contained foreign content", + value.writer + ); + reads += 1; + } + Err(e) => panic!("reader saw IO/parse error: {e}"), + } + } + reads + }); + + for h in handles { + h.join().unwrap(); + } + done.store(true, Ordering::Relaxed); + let reads = reader.join().unwrap(); + assert!(reads > 0, "reader never observed any successful load"); + + // Final state should be a clean, complete JSON from one of the writers. + let final_value: Big = store.load("contended.json").unwrap(); + assert_eq!(final_value.payload.len(), payload_size); + + // No staging files should be left behind after all writers finished. + let leftovers: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name(); + let s = name.to_string_lossy(); + s.starts_with(".contended.json.tmp.") + }) + .collect(); + assert!( + leftovers.is_empty(), + "tmp staging files leaked: {leftovers:?}" + ); + } + + mod workspace { + use super::*; + use crate::ProfileData; + + const WS_A: &str = "AAAAAAAAAAAAAAAA"; + const WS_B: &str = "BBBBBBBBBBBBBBBB"; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct WsData { + name: String, + } + + impl ProfileData for WsData { + const FILENAME: &'static str = "ws-data.json"; + } + + mod given_no_workspace_set { + use super::*; + + #[test] + fn current_workspace_returns_no_current_workspace() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.current_workspace().unwrap_err(); + assert!( + matches!(err, ProfileError::NoCurrentWorkspace), + "expected NoCurrentWorkspace, got: {err:?}" + ); + } + + #[test] + fn current_workspace_store_returns_no_current_workspace() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.current_workspace_store().unwrap_err(); + assert!( + matches!(err, ProfileError::NoCurrentWorkspace), + "expected NoCurrentWorkspace, got: {err:?}" + ); + } + + #[test] + fn clear_current_workspace_succeeds() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + store.clear_current_workspace().unwrap(); + } + + #[test] + fn set_current_workspace_returns_workspace_not_found() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.set_current_workspace(WS_A).unwrap_err(); + assert!( + matches!(err, ProfileError::WorkspaceNotFound(_)), + "expected WorkspaceNotFound, got: {err:?}" + ); + } + + #[test] + fn init_workspace_creates_dir_and_sets_current() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + store.init_workspace(WS_A).unwrap(); + assert_eq!( + store.current_workspace().unwrap(), + WS_A, + "init_workspace should set the current workspace" + ); + assert!( + dir.path().join("workspaces").join(WS_A).is_dir(), + "init_workspace should create the workspace directory" + ); + } + } + + mod given_workspace_set { + use super::*; + + fn scenario() -> (tempfile::TempDir, ProfileStore) { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + store.init_workspace(WS_A).unwrap(); + (dir, store) + } + + #[test] + fn returns_workspace_id() { + let (_dir, store) = scenario(); + assert_eq!( + store.current_workspace().unwrap(), + WS_A, + "should return the workspace that was set" + ); + } + + #[test] + fn current_workspace_store_returns_scoped_store() { + let (dir, store) = scenario(); + let ws_store = store.current_workspace_store().unwrap(); + assert_eq!( + ws_store.dir(), + dir.path().join("workspaces").join(WS_A), + "workspace store should be rooted in workspaces/<id>" + ); + } + + #[test] + fn clear_removes_selection() { + let (_dir, store) = scenario(); + store.clear_current_workspace().unwrap(); + + let err = store.current_workspace().unwrap_err(); + assert!( + matches!(err, ProfileError::NoCurrentWorkspace), + "expected NoCurrentWorkspace after clear, got: {err:?}" + ); + } + + #[test] + fn save_and_load_round_trips_through_workspace_store() { + let (dir, store) = scenario(); + let ws_store = store.current_workspace_store().unwrap(); + + let data = WsData { + name: "hello".into(), + }; + ws_store.save_profile(&data).unwrap(); + + let loaded: WsData = ws_store.load_profile().unwrap(); + assert_eq!(loaded, data, "workspace store should round-trip data"); + + assert!( + dir.path() + .join("workspaces") + .join(WS_A) + .join("ws-data.json") + .exists(), + "file should be in the workspace directory" + ); + assert!( + !store.exists_profile::<WsData>(), + "root store should not see workspace-scoped file" + ); + } + } + + mod given_multiple_workspaces { + use super::*; + + fn scenario() -> (tempfile::TempDir, ProfileStore) { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + store + .workspace_store(WS_A) + .unwrap() + .save_profile(&WsData { + name: "alpha".into(), + }) + .unwrap(); + store + .workspace_store(WS_B) + .unwrap() + .save_profile(&WsData { + name: "bravo".into(), + }) + .unwrap(); + + (dir, store) + } + + #[test] + fn switching_changes_current_workspace_store_data() { + let (_dir, store) = scenario(); + + store.set_current_workspace(WS_A).unwrap(); + let loaded: WsData = store + .current_workspace_store() + .unwrap() + .load_profile() + .unwrap(); + assert_eq!( + loaded.name, "alpha", + "should load workspace A data after switching to A" + ); + + store.set_current_workspace(WS_B).unwrap(); + let loaded: WsData = store + .current_workspace_store() + .unwrap() + .load_profile() + .unwrap(); + assert_eq!( + loaded.name, "bravo", + "should load workspace B data after switching to B" + ); + } + + #[test] + fn list_workspaces_returns_sorted_ids() { + let (_dir, store) = scenario(); + + let workspaces = store.list_workspaces().unwrap(); + assert_eq!( + workspaces, + vec![WS_A, WS_B], + "should list both workspaces in sorted order" + ); + } + } + + mod list_workspaces { + use super::*; + + #[test] + fn returns_empty_when_no_workspaces_dir() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + assert_eq!( + store.list_workspaces().unwrap(), + Vec::<String>::new(), + "should return empty list when workspaces/ does not exist" + ); + } + + #[test] + fn ignores_files_and_invalid_dirs() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let ws_dir = dir.path().join("workspaces"); + std::fs::create_dir_all(&ws_dir).unwrap(); + std::fs::create_dir(ws_dir.join(WS_A)).unwrap(); + std::fs::write(ws_dir.join("not-a-dir.txt"), "").unwrap(); + std::fs::create_dir(ws_dir.join("invalid-name")).unwrap(); + + let workspaces = store.list_workspaces().unwrap(); + assert_eq!( + workspaces, + vec![WS_A], + "should only include valid workspace directories" + ); + } + } + + mod workspace_store { + use super::*; + + #[test] + fn returns_scoped_store() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let ws_store = store.workspace_store(WS_A).unwrap(); + assert_eq!( + ws_store.dir(), + dir.path().join("workspaces").join(WS_A), + "workspace store should be rooted in workspaces/<id>" + ); + } + + #[test] + fn rejects_invalid_id() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.workspace_store("../escape").unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidWorkspaceId(_)), + "expected InvalidWorkspaceId for path traversal, got: {err:?}" + ); + } + } + + mod validate_workspace_id { + use super::*; + + #[test] + fn accepts_valid_base32() { + ProfileStore::validate_workspace_id("ABCDEFGH234567AB").unwrap(); + ProfileStore::validate_workspace_id(WS_A).unwrap(); + } + + #[test] + fn rejects_lowercase() { + let err = ProfileStore::validate_workspace_id("abcdefgh234567ab").unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidWorkspaceId(_)), + "expected InvalidWorkspaceId for lowercase, got: {err:?}" + ); + } + + #[test] + fn rejects_wrong_length() { + let err = ProfileStore::validate_workspace_id("SHORT").unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidWorkspaceId(_)), + "expected InvalidWorkspaceId for short string, got: {err:?}" + ); + } + + #[test] + fn rejects_empty() { + let err = ProfileStore::validate_workspace_id("").unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidWorkspaceId(_)), + "expected InvalidWorkspaceId for empty string, got: {err:?}" + ); + } + + #[test] + fn rejects_path_traversal() { + let err = ProfileStore::validate_workspace_id("../escape.json..").unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidWorkspaceId(_)), + "expected InvalidWorkspaceId for path traversal, got: {err:?}" + ); + } + + #[test] + fn rejects_non_base32_digits() { + let err = ProfileStore::validate_workspace_id("0000000000000000").unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidWorkspaceId(_)), + "expected InvalidWorkspaceId for digits outside base32 alphabet, got: {err:?}" + ); + } + } + + mod migrate_to_workspace { + use super::*; + + mod given_legacy_flat_files { + use super::*; + + fn scenario() -> (tempfile::TempDir, ProfileStore) { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + std::fs::create_dir_all(dir.path()).unwrap(); + std::fs::write(dir.path().join("auth.json"), r#"{"token":"old"}"#).unwrap(); + std::fs::write(dir.path().join("secretkey.json"), r#"{"key":"old"}"#).unwrap(); + (dir, store) + } + + #[test] + fn moves_files_to_workspace_dir() { + let (dir, store) = scenario(); + store.migrate_to_workspace(WS_A).unwrap(); + + assert!( + !dir.path().join("auth.json").exists(), + "legacy auth.json should be removed from root" + ); + assert!( + !dir.path().join("secretkey.json").exists(), + "legacy secretkey.json should be removed from root" + ); + + let ws_dir = dir.path().join("workspaces").join(WS_A); + assert!( + ws_dir.join("auth.json").exists(), + "auth.json should be in workspace dir" + ); + assert!( + ws_dir.join("secretkey.json").exists(), + "secretkey.json should be in workspace dir" + ); + } + + #[test] + fn sets_current_workspace() { + let (_dir, store) = scenario(); + store.migrate_to_workspace(WS_A).unwrap(); + assert_eq!( + store.current_workspace().unwrap(), + WS_A, + "current workspace should be set after migration" + ); + } + } + + mod given_existing_files_in_target { + use super::*; + + #[test] + fn does_not_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + std::fs::create_dir_all(dir.path()).unwrap(); + std::fs::write(dir.path().join("auth.json"), r#"{"token":"legacy"}"#).unwrap(); + + let ws_dir = dir.path().join("workspaces").join(WS_A); + std::fs::create_dir_all(&ws_dir).unwrap(); + std::fs::write(ws_dir.join("auth.json"), r#"{"token":"existing"}"#).unwrap(); + + store.migrate_to_workspace(WS_A).unwrap(); + + let contents = std::fs::read_to_string(ws_dir.join("auth.json")).unwrap(); + assert!( + contents.contains("existing"), + "workspace file should be unchanged, got: {contents}" + ); + assert!( + dir.path().join("auth.json").exists(), + "legacy file should remain when target exists" + ); + } + } + + mod given_no_legacy_files { + use super::*; + + #[test] + fn sets_current_workspace() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + store.migrate_to_workspace(WS_A).unwrap(); + assert_eq!( + store.current_workspace().unwrap(), + WS_A, + "should set current workspace even without legacy files" + ); + } + } + } + } +} diff --git a/packages/stack-profile/tasks.toml b/packages/stack-profile/tasks.toml new file mode 100644 index 000000000..7e49af0a6 --- /dev/null +++ b/packages/stack-profile/tasks.toml @@ -0,0 +1,21 @@ +["test:integration:stack-profile"] +description = "Run stack-profile Node.js integration tests" +dir = "{{config_root}}/languages/typescript/packages/profile" +run = [ + "cargo build -p stack-profile-node", + "cp ../../../../target/debug/libstack_profile_node.dylib stack-profile-node.node 2>/dev/null || cp ../../../../target/debug/libstack_profile_node.so stack-profile-node.node", + "pnpm exec vitest run", +] + +# Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc +# warning fails the build. Doc *examples* are `test:doc:stack-profile`. Both run +# with all features so nothing feature-gated goes unchecked; the root `doc` +# task fans out over every `doc:<crate>`. +["doc:stack-profile"] +description = "Build docs for stack-profile with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-profile --no-deps --all-features" + +["test:doc:stack-profile"] +description = "Run documentation tests for stack-profile" +run = "mise x --env test -- cargo test -p stack-profile --doc --all-features" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0f4875bb..b6c7e370d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,52 @@ importers: languages/typescript/examples/supabase-worker: {} + languages/typescript/packages/auth: + dependencies: + '@byteslice/result': + specifier: ^0.3.0 + version: 0.3.0 + '@cipherstash/auth-darwin-arm64': + specifier: workspace:* + version: link:platforms/darwin-arm64 + '@cipherstash/auth-darwin-x64': + specifier: workspace:* + version: link:platforms/darwin-x64 + '@cipherstash/auth-linux-arm64-gnu': + specifier: workspace:* + version: link:platforms/linux-arm64-gnu + '@cipherstash/auth-linux-x64-gnu': + specifier: workspace:* + version: link:platforms/linux-x64-gnu + '@cipherstash/auth-linux-x64-musl': + specifier: workspace:* + version: link:platforms/linux-x64-musl + '@cipherstash/auth-win32-x64-msvc': + specifier: workspace:* + version: link:platforms/win32-x64-msvc + devDependencies: + '@napi-rs/cli': + specifier: ^2 + version: 2.18.4 + typescript: + specifier: ^5 + version: 5.9.3 + vitest: + specifier: ^3 + version: 3.2.7(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + + languages/typescript/packages/auth/platforms/darwin-arm64: {} + + languages/typescript/packages/auth/platforms/darwin-x64: {} + + languages/typescript/packages/auth/platforms/linux-arm64-gnu: {} + + languages/typescript/packages/auth/platforms/linux-x64-gnu: {} + + languages/typescript/packages/auth/platforms/linux-x64-musl: {} + + languages/typescript/packages/auth/platforms/win32-x64-msvc: {} + languages/typescript/packages/bench: dependencies: '@cipherstash/stack': @@ -355,6 +401,49 @@ importers: specifier: 4.62.4 version: 4.62.4 + languages/typescript/packages/profile: + devDependencies: + '@napi-rs/cli': + specifier: ^2 + version: 2.18.4 + typescript: + specifier: ^5 + version: 5.9.3 + vitest: + specifier: ^3 + version: 3.2.7(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + optionalDependencies: + '@cipherstash/profile-darwin-arm64': + specifier: workspace:* + version: link:platforms/darwin-arm64 + '@cipherstash/profile-darwin-x64': + specifier: workspace:* + version: link:platforms/darwin-x64 + '@cipherstash/profile-linux-arm64-gnu': + specifier: workspace:* + version: link:platforms/linux-arm64-gnu + '@cipherstash/profile-linux-x64-gnu': + specifier: workspace:* + version: link:platforms/linux-x64-gnu + '@cipherstash/profile-linux-x64-musl': + specifier: workspace:* + version: link:platforms/linux-x64-musl + '@cipherstash/profile-win32-x64-msvc': + specifier: workspace:* + version: link:platforms/win32-x64-msvc + + languages/typescript/packages/profile/platforms/darwin-arm64: {} + + languages/typescript/packages/profile/platforms/darwin-x64: {} + + languages/typescript/packages/profile/platforms/linux-arm64-gnu: {} + + languages/typescript/packages/profile/platforms/linux-x64-gnu: {} + + languages/typescript/packages/profile/platforms/linux-x64-musl: {} + + languages/typescript/packages/profile/platforms/win32-x64-msvc: {} + languages/typescript/packages/protect-ffi: dependencies: '@neon-rs/load': @@ -540,6 +629,8 @@ importers: specifier: catalog:repo version: 0.44.0 + languages/typescript/packages/stack-auth-wasm: {} + languages/typescript/packages/stack-drizzle: dependencies: '@byteslice/result': @@ -1453,6 +1544,11 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/cli@2.18.4': + resolution: {integrity: sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==} + engines: {node: '>= 10'} + hasBin: true + '@neon-rs/cli@0.2.6': resolution: {integrity: sha512-SC8xYNOH1dTnDiB2pykFhREl74gs6TF4jr2qFp0h9fwriRSoxic/CnVN2d7ikpc3XatOK1QnwfQCCmyv31x9/A==} hasBin: true @@ -1863,9 +1959,23 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ~7.3.5 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.1.11': resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: @@ -1877,18 +1987,33 @@ packages: vite: optional: true + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + '@vitest/pretty-format@4.1.11': resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + '@vitest/runner@4.1.11': resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + '@vitest/snapshot@4.1.11': resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + '@vitest/spy@4.1.11': resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} @@ -2010,6 +2135,10 @@ packages: caniuse-lite@1.0.30001760: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -2017,6 +2146,10 @@ packages: chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2104,6 +2237,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -2253,6 +2390,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -2596,6 +2736,9 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.15.2: resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} hasBin: true @@ -2720,6 +2863,9 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.3.6: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} @@ -2914,6 +3060,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -3257,6 +3407,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -3280,6 +3433,9 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -3336,10 +3492,22 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.1.1: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3437,6 +3605,11 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@7.3.6: resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3477,6 +3650,34 @@ packages: yaml: optional: true + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.1.11: resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4178,6 +4379,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/cli@2.18.4': {} + '@neon-rs/cli@0.2.6': {} '@neon-rs/load@0.2.6': {} @@ -4585,7 +4788,15 @@ snapshots: obug: 2.2.1 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 '@vitest/expect@4.1.11': dependencies: @@ -4596,6 +4807,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + '@vitest/mocker@4.1.11(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 @@ -4612,15 +4831,31 @@ snapshots: optionalDependencies: vite: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.1 + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/runner@4.1.11': dependencies: '@vitest/utils': 4.1.11 pathe: 2.0.3 + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.1.11': dependencies: '@vitest/pretty-format': 4.1.11 @@ -4628,8 +4863,18 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + '@vitest/spy@4.1.11': {} + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vitest/utils@4.1.11': dependencies: '@vitest/pretty-format': 4.1.11 @@ -4759,10 +5004,20 @@ snapshots: caniuse-lite@1.0.30001760: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.2: {} chardet@2.2.0: {} + check-error@2.1.3: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -4823,6 +5078,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + defu@6.1.7: {} depd@2.0.0: {} @@ -4871,6 +5128,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-module-lexer@2.3.2: {} es-object-atoms@1.1.2: @@ -5230,6 +5489,8 @@ snapshots: js-tokens@10.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.15.2: dependencies: argparse: 1.0.10 @@ -5330,6 +5591,8 @@ snapshots: lodash@4.18.1: {} + loupe@3.2.1: {} + lru-cache@11.3.6: {} magic-string@0.30.21: @@ -5492,6 +5755,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + perfect-debounce@2.1.0: {} pg-cloudflare@1.4.0: @@ -5863,6 +6128,8 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + std-env@4.2.0: {} string-width@8.2.2: @@ -5882,6 +6149,10 @@ snapshots: strip-final-newline@4.0.0: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + styled-jsx@5.1.6(react@19.2.3): dependencies: client-only: 0.0.1 @@ -5935,8 +6206,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + tinyrainbow@3.1.1: {} + tinyspy@4.0.6: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6027,6 +6304,27 @@ snapshots: vary@1.1.2: {} + vite-node@3.2.4(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0): dependencies: esbuild: 0.28.1 @@ -6061,6 +6359,47 @@ snapshots: tsx: 4.23.12 yaml: 2.9.0 + vitest@3.2.7(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.2.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 07b7bc054..c33a93f3c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,12 @@ packages: # glob above already covers the wrapper itself; these are nested a level # deeper and need their own entry. - languages/typescript/packages/protect-ffi/platforms/* + # The node bindings imported from cipherstash-suite. `auth`, `profile` and + # `stack-auth-wasm` sit one level down and are already selected by the first + # glob; the per-platform packages of `@cipherstash/auth` and + # `@cipherstash/profile` are a level deeper, like protect-ffi's. + - languages/typescript/packages/auth/platforms/* + - languages/typescript/packages/profile/platforms/* # @cipherstash/eql, from the encrypt-query-language subtree. The subtree is # imported at a VERBATIM prefix so its repo-root-relative paths keep resolving # (mise tasks, Doxyfile, sync-generated.mjs), which puts the npm package two diff --git a/scripts/__tests__/cargo-lock-freshness.test.mjs b/scripts/__tests__/cargo-lock-freshness.test.mjs index ce942ba84..ea2424951 100644 --- a/scripts/__tests__/cargo-lock-freshness.test.mjs +++ b/scripts/__tests__/cargo-lock-freshness.test.mjs @@ -1,5 +1,5 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { join, relative, sep } from 'node:path' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, join, relative, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { REPO_ROOT } from './lib/repo-root.mjs' @@ -77,12 +77,33 @@ function findFiles(name) { * `version = "…"` line, and reading "the first version in the file" would pick * up whichever came first. */ -function crateManifest(source) { +function crateManifest(source, file) { const section = source.match(/^\[package\]\n(?:(?!^\[).*\n)*/m) if (!section) return null const name = /^name = "([^"]*)"$/m.exec(section[0]) - const version = /^version = "([^"]*)"$/m.exec(section[0]) - return name && version ? { name: name[1], version: version[1] } : null + const version = /^version\.workspace = true$/m.test(section[0]) + ? workspaceVersion(file) + : /^version = "([^"]*)"$/m.exec(section[0])?.[1] + return name && version ? { name: name[1], version } : null +} + +/** + * `[workspace.package] version` from the nearest enclosing workspace root, for + * a crate that declares `version.workspace = true` (the node binding crates in + * the root workspace). + */ +function workspaceVersion(file) { + let dir = dirname(dirname(join(REPO_ROOT, file))) + for (;;) { + const manifest = join(dir, 'Cargo.toml') + if (existsSync(manifest)) { + const source = readFileSync(manifest, 'utf8') + const table = source.match(/^\[workspace\.package\]\n(?:(?!^\[).*\n)*/m) + if (table) return /^version = "([^"]*)"$/m.exec(table[0])?.[1] ?? null + } + if (dir === REPO_ROOT || dirname(dir) === dir) return null + dir = dirname(dir) + } } /** @@ -112,7 +133,7 @@ export function localLockEntries(source) { const CRATES = new Map() const AMBIGUOUS = [] for (const file of findFiles('Cargo.toml')) { - const crate = crateManifest(readFileSync(join(REPO_ROOT, file), 'utf8')) + const crate = crateManifest(readFileSync(join(REPO_ROOT, file), 'utf8'), file) if (!crate) continue // a virtual manifest: `[workspace]` with no `[package]` const existing = CRATES.get(crate.name) if (existing && existing.version !== crate.version) { @@ -148,9 +169,31 @@ describe('Cargo.lock records this tree’s crates at their real versions', () => // Named specifically because it is the one with a mechanism actively // pushing it out of sync: `scripts/sync-lockstep-versions.mjs` writes its // `Cargo.toml` on every release. If this crate ever drops out of the pair - // set, the check that matters most has silently stopped running. - expect(PAIRS.filter(({ name }) => name === 'eql-bindings').length).toBe( - LOCKS.length, + // set, the check that matters most has silently stopped running. It is + // in the two locks whose workspaces build it; the stack-* workspaces do + // not depend on it. + expect( + PAIRS.filter(({ name }) => name === 'eql-bindings') + .map(({ lock }) => lock) + .sort(), + ).toEqual([ + 'languages/typescript/packages/protect-ffi/Cargo.lock', + 'packages/eql/Cargo.lock', + ]) + }) + + it('finds the root lock and the five detached stack-* locks', () => { + // The root workspace and the workspaces it excludes: the three cargo-fuzz + // crates and the two Go WASI guests, each with its own lock. + expect(LOCKS).toEqual( + expect.arrayContaining([ + 'Cargo.lock', + 'packages/stack-auth/fuzz/Cargo.lock', + 'packages/stack-kms/fuzz/Cargo.lock', + 'packages/stack-encrypt/fuzz/Cargo.lock', + 'languages/golang/stackencrypt/guest/Cargo.lock', + 'languages/golang/stackauth/guest/Cargo.lock', + ]), ) }) diff --git a/scripts/__tests__/cargo-publish-opt-out.test.mjs b/scripts/__tests__/cargo-publish-opt-out.test.mjs index 41d2b7a90..60ae2f071 100644 --- a/scripts/__tests__/cargo-publish-opt-out.test.mjs +++ b/scripts/__tests__/cargo-publish-opt-out.test.mjs @@ -4,8 +4,10 @@ import { describe, expect, it } from 'vitest' import { REPO_ROOT } from './lib/repo-root.mjs' /** - * Every crate in BOTH nested Cargo workspaces must opt out of crates.io unless - * it is deliberately allowlisted below. + * Every crate in every Cargo workspace must opt out of crates.io unless it is + * deliberately allowlisted below: the root workspace (the stack-* crates), + * EQL, protect-ffi, and the single-package workspaces the root excludes (the + * three cargo-fuzz crates and the two Go WASI guests). * * A crate with no `publish` key is publishable BY DEFAULT, and release-plz * publishes every workspace member that has not opted out. The convention is @@ -50,6 +52,24 @@ const WORKSPACES = [ publishable: new Set(), expects: 'crates/protect-ffi', }, + { + // The root workspace. The stack-* crates' release-plz step publishes + // exactly these two (the stack-kms, stack-encrypt, stack-encrypt-derive + // and stack-guest-abi names are unclaimed on crates.io, and publishing + // them is a separate decision). + root: '.', + publishable: new Set(['packages/stack-auth', 'packages/stack-profile']), + expects: 'packages/stack-auth', + }, + // Single-package workspaces: `[workspace]` with no members, so the package + // at the root is the one member. + ...[ + 'packages/stack-auth/fuzz', + 'packages/stack-kms/fuzz', + 'packages/stack-encrypt/fuzz', + 'languages/golang/stackencrypt/guest', + 'languages/golang/stackauth/guest', + ].map((root) => ({ root, publishable: new Set(), expects: '.' })), ] /** @@ -64,8 +84,11 @@ const WORKSPACES = [ */ function workspaceMembers(WORKSPACE) { const manifest = readFileSync(join(WORKSPACE, 'Cargo.toml'), 'utf8') - const block = /^members\s*=\s*\[([^\]]*)\]/m.exec(manifest)?.[1] ?? '' - return [...block.matchAll(/"([^"]+)"/g)] + const block = /^members\s*=\s*\[([^\]]*)\]/m.exec(manifest)?.[1] + // A `[workspace]` with no `members` whose manifest is also a `[package]`: + // cargo's single-package workspace, whose one member is the root itself. + if (block === undefined && /^\[package\]$/m.test(manifest)) return ['.'] + return [...(block ?? '').matchAll(/"([^"]+)"/g)] .flatMap(([, pattern]) => pattern.endsWith('/*') ? readdirSync(join(WORKSPACE, pattern.slice(0, -2)), { diff --git a/scripts/__tests__/frozen-publisher-docs.test.mjs b/scripts/__tests__/frozen-publisher-docs.test.mjs index 774b55bbd..0867ae307 100644 --- a/scripts/__tests__/frozen-publisher-docs.test.mjs +++ b/scripts/__tests__/frozen-publisher-docs.test.mjs @@ -47,10 +47,20 @@ import { REPO_ROOT } from './lib/repo-root.mjs' */ const EQL = '@cipherstash/eql' +const AUTH = '@cipherstash/auth' -/** Where the freeze is explained, and the instruction each file carries. */ +/** + * Where each freeze is explained, and the instruction each file carries. + * + * `pkg` is the map key the instruction is about. The @cipherstash/auth freeze + * (the wrapper and its six platform packages, keyed here by the wrapper) is + * deleted by the arming PR of the stack-* crates import, and its prose goes + * with it. `historical` is the wording each instruction was written for, + * which keeps an assertion of absence able to fail once a freeze is deleted. + */ const DOCS = [ { + pkg: EQL, file: 'AGENTS.md', // Both spellings resolve to "the `@cipherstash/eql` entry", which is the // thing Phase 5 deletes — matching that keeps the guard anchored to the @@ -59,6 +69,7 @@ const DOCS = [ historical: 'Delete the `@cipherstash/eql` entry in the Phase-5 cutover.', }, { + pkg: EQL, file: 'docs/plans/2026-08-13-eql-monorepo-absorption.md', instruction: /`@cipherstash\/eql` entry/, historical: @@ -66,6 +77,7 @@ const DOCS = [ '`FROZEN_PUBLISHERS`, nothing more.', }, { + pkg: EQL, file: 'SECURITY.md', // Not a regex, and deliberately: this is `foreignPublishClaims` run over // the file, so the SAME extractor that forbids a wrong name below is what @@ -78,6 +90,24 @@ const DOCS = [ '> developed here but are *published* from `cipherstash/encrypt-query-language`\n' + '> until the Phase 5 cutover in\n', }, + { + pkg: AUTH, + file: 'AGENTS.md', + instruction: /`@cipherstash\/auth\*?` entries/, + historical: + '**Delete the `@cipherstash/auth*` entries in the arming PR of the stack-*\n' + + ' crates import.**', + }, + { + pkg: AUTH, + file: 'SECURITY.md', + instruction: (body) => foreignPublishClaims(body).includes(AUTH), + historical: + '> **Note on publishing.** `@cipherstash/auth` and its six platform packages,\n' + + '> and the `stack-auth` and `stack-profile` crates, are developed here but are\n' + + '> *published* from `cipherstash/cipherstash-suite` until the arming PR of the\n' + + '> stack-* crates import repoints them.\n', + }, ] /** @@ -188,7 +218,7 @@ const satisfies = (instruction, body) => describe('frozen-publisher docs track the map', () => { it.each(DOCS)( - '$file instruction still recognises the freeze wording it was written for', + '$file instruction still recognises the $pkg freeze wording it was written for', ({ instruction, historical }) => { // With EQL out of the map, the `iff` below asserts ABSENCE, which an // instruction that matches nothing passes vacuously. The pre-cutover @@ -198,20 +228,22 @@ describe('frozen-publisher docs track the map', () => { ) it.each(DOCS)( - '$file documents the eql freeze iff the map carries it', - ({ file, instruction }) => { + '$file documents the $pkg freeze iff the map carries it', + ({ pkg, file, instruction }) => { expect( satisfies(instruction, read(file)), - FROZEN_PUBLISHERS.has(EQL) - ? `${file} no longer tells an agent about the ${EQL} freeze, but ` + + FROZEN_PUBLISHERS.has(pkg) + ? `${file} no longer tells an agent about the ${pkg} freeze, but ` + 'FROZEN_PUBLISHERS still carries it.' - : `${EQL} has left FROZEN_PUBLISHERS (Phase-5 cutover), so ${file} ` + + : `${pkg} has left FROZEN_PUBLISHERS (its publishing cutover), so ${file} ` + 'must stop instructing agents about the freeze.', - ).toBe(FROZEN_PUBLISHERS.has(EQL)) + ).toBe(FROZEN_PUBLISHERS.has(pkg)) }, ) - it.each(DOCS)('$file freezes only what the map freezes', ({ file }) => { + const FILES = [...new Set(DOCS.map(({ file }) => file))] + + it.each(FILES)('%s freezes only what the map freezes', (file) => { const claimed = [...new Set(foreignPublishClaims(read(file)))] expect( claimed.filter((name) => !FROZEN_PUBLISHERS.has(name)), @@ -223,7 +255,7 @@ describe('frozen-publisher docs track the map', () => { ).toEqual([]) }) - it.each(DOCS)('$file asserts no live gate verdict', ({ file }) => { + it.each(FILES)('%s asserts no live gate verdict', (file) => { const body = read(file) expect( LIVE_VERDICT_CLAIMS.filter((claim) => claim.test(body)).map(String), diff --git a/scripts/__tests__/go-module-path.test.mjs b/scripts/__tests__/go-module-path.test.mjs new file mode 100644 index 000000000..260e5f129 --- /dev/null +++ b/scripts/__tests__/go-module-path.test.mjs @@ -0,0 +1,54 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' + +/** + * The Go module lives at its stack path. The suite's path named a private + * repository whose `bindings/go` folder the suite removal deletes, so a + * `go get` of it can never resolve. + * + * `go vet` catches a stale import, but not a stale `go get` line or + * pkg.go.dev link in a README. The cutover re-export can bring those back in + * new or changed files, which is why the text check covers every tracked file. + * `docs/plans/` keeps the old path as history. + */ + +const MODULE = 'github.com/cipherstash/stack/languages/golang' +const SUITE_MODULE = 'github.com/cipherstash/cipherstash-suite/bindings/go' +const SELF = 'scripts/__tests__/go-module-path.test.mjs' + +function filesNaming(needle) { + try { + return execFileSync( + 'git', + ['grep', '-l', '-F', needle, '--', '.', ':!docs/plans/', `:!${SELF}`], + { cwd: REPO_ROOT, encoding: 'utf8' }, + ) + .split('\n') + .filter(Boolean) + } catch (error) { + // git grep exits 1 when nothing matches. + if (error.status === 1) return [] + throw error + } +} + +describe('Go module path', () => { + it('go.mod declares the stack path', () => { + const goMod = readFileSync( + join(REPO_ROOT, 'languages/golang/go.mod'), + 'utf8', + ) + expect(goMod.split('\n')[0]).toBe(`module ${MODULE}`) + }) + + it('no tracked file outside docs/plans/ names the suite path', () => { + expect(filesNaming(SUITE_MODULE)).toEqual([]) + }) + + it('the text check sees a match when there is one', () => { + expect(filesNaming(MODULE)).toContain('languages/golang/go.mod') + }) +}) diff --git a/scripts/__tests__/lint-no-auth-changeset.test.mjs b/scripts/__tests__/lint-no-auth-changeset.test.mjs new file mode 100644 index 000000000..109c66a61 --- /dev/null +++ b/scripts/__tests__/lint-no-auth-changeset.test.mjs @@ -0,0 +1,142 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, describe, expect, it } from 'vitest' +import { FROZEN_PUBLISHERS, workspaceManifests } from '../release-gate.mjs' + +const SCRIPT = resolve( + fileURLToPath(import.meta.url), + '../../lint-no-auth-changeset.mjs', +) + +function run(dir) { + try { + const stdout = execFileSync('node', dir ? [SCRIPT, dir] : [SCRIPT], { + encoding: 'utf8', + }) + return { exitCode: 0, output: stdout } + } catch (err) { + return { + exitCode: err.status, + output: String(err.stdout) + String(err.stderr), + } + } +} + +// Generated rather than committed: a committed CRLF fixture is one +// `autocrlf=true` checkout away from being normalised to LF. +const tempDirs = [] +function changesets(files) { + const dir = mkdtempSync(join(tmpdir(), 'auth-changeset-')) + tempDirs.push(dir) + for (const [name, body] of Object.entries(files)) { + writeFileSync(join(dir, name), body) + } + return dir +} +afterAll(() => { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }) +}) + +describe('lint-no-auth-changeset', () => { + it('passes against the real .changeset directory', () => { + expect(run().exitCode).toBe(0) + }) + + it('passes on changesets that name no auth package', () => { + const dir = changesets({ + 'happy-otter-sing.md': + "---\n'@cipherstash/stack': patch\n---\n\nA fix.\n", + }) + expect(run(dir).exitCode).toBe(0) + }) + + it('does not parse README.md as a changeset', () => { + // Guarded frontmatter in the README, so this passes only because of the skip. + const dir = changesets({ + 'README.md': "---\n'@cipherstash/auth': minor\n---\n\nNot a changeset.\n", + }) + const { exitCode, output } = run(dir) + expect(exitCode).toBe(0) + expect(output).not.toMatch(/README/) + }) + + it('fails when a changeset names the wrapper, and reports every file', () => { + const dir = changesets({ + 'brave-lion-jump.md': + "---\n'@cipherstash/auth': minor\n---\n\nNew API.\n", + 'quiet-moth-wait.md': + "---\n'@cipherstash/auth-linux-x64-musl': patch\n---\n\nRebuild.\n", + }) + const { exitCode, output } = run(dir) + expect(exitCode).toBe(1) + expect(output).toMatch('brave-lion-jump.md') + expect(output).toMatch('quiet-moth-wait.md') + expect(output).toMatch('@cipherstash/auth-linux-x64-musl') + }) + + it('catches an auth package on any frontmatter line, not just the first', () => { + const dir = changesets({ + 'wise-crane-list.md': + "---\n'@cipherstash/stack': patch\n'@cipherstash/auth-darwin-arm64': patch\n---\n\nBoth.\n", + }) + const { exitCode, output } = run(dir) + expect(exitCode).toBe(1) + expect(output).toMatch('@cipherstash/auth-darwin-arm64') + }) + + it('parses a changeset checked out with CRLF line endings', () => { + const dir = changesets({ + 'tidy-vole-climb.md': + "---\r\n'@cipherstash/stack': patch\r\n'@cipherstash/auth-linux-arm64-gnu': patch\r\n---\r\n\r\nWritten on Windows.\r\n", + }) + const { exitCode, output } = run(dir) + expect(exitCode).toBe(1) + expect(output).toMatch('@cipherstash/auth-linux-arm64-gnu') + }) + + it('ignores an auth package named only in the prose body', () => { + const dir = changesets({ + 'gentle-fox-run.md': + "---\n'@cipherstash/stack': patch\n---\n\nUses `'@cipherstash/auth': minor` internally.\n", + }) + expect(run(dir).exitCode).toBe(0) + }) + + it('does not guard a name that only starts with auth', () => { + const dir = changesets({ + 'odd-name.md': + "---\n'@cipherstash/authority': patch\n---\n\nUnrelated.\n", + }) + expect(run(dir).exitCode).toBe(0) + }) + + it('names its own removal condition in the source', () => { + const source = readFileSync(SCRIPT, 'utf8') + expect(source).toMatch(/TEMPORARY/) + expect(source).toMatch(/trusted\s+publishing/) + }) + + it('guards exactly the frozen auth packages, which are the auth workspace packages', () => { + // Three lists name the same seven packages until PR E: this guard, the + // release gate's freeze, and the workspace. Drift between any two lets a + // platform package through while the others still treat it as frozen. + const guarded = [ + ...readFileSync(SCRIPT, 'utf8').matchAll( + /'(@cipherstash\/auth(?:-[a-z0-9-]+)?)'/g, + ), + ].map(([, name]) => name) + const isAuth = (name) => + name === '@cipherstash/auth' || name.startsWith('@cipherstash/auth-') + const frozen = [...FROZEN_PUBLISHERS.keys()].filter(isAuth) + const workspace = workspaceManifests() + .map((manifest) => manifest.name) + .filter(isAuth) + + expect([...new Set(guarded)].sort()).toHaveLength(7) + expect([...new Set(guarded)].sort()).toEqual([...frozen].sort()) + expect([...new Set(guarded)].sort()).toEqual([...workspace].sort()) + }) +}) diff --git a/scripts/__tests__/release-gate.test.mjs b/scripts/__tests__/release-gate.test.mjs index efb849114..1c98e45be 100644 --- a/scripts/__tests__/release-gate.test.mjs +++ b/scripts/__tests__/release-gate.test.mjs @@ -39,6 +39,7 @@ import { readWorkflow } from './lib/workflows.mjs' const FFI = '@cipherstash/protect-ffi' const PLATFORM = '@cipherstash/protect-ffi-darwin-arm64' +const AUTH = '@cipherstash/auth' describe('unpublished', () => { it('reports a package whose committed version is not on the registry', () => { @@ -760,10 +761,12 @@ describe('the gate actually blocks the publish', () => { * `npmVersions` shells out to it. That also keeps this offline and * deterministic. * - * WITH THE REAL MAPS EMPTY, the blocking path needs a frozen package, so most - * of these run `main()` with the EQL fixture injected through its parameters — - * a separate process importing the module, never a flag the real script reads. - * One runs the script itself, to hold the real maps to "EQL is not frozen". + * THE REAL MAPS DO NOT FREEZE EQL, so the EQL blocking path needs a frozen + * package, and most of these run `main()` with the EQL fixture injected + * through its parameters — a separate process importing the module, never a + * flag the real script reads. Two run the script itself: one holds the real + * maps to "EQL is not frozen", and one drives the `@cipherstash/auth` freeze + * through them. * * THE SHIM ANSWERS `pack` AS WELL AS `view`, and that is not tidying. It used * to answer `view` only, so `publishedArtefactDigest`'s `npm pack` got a @@ -803,6 +806,19 @@ describe('the gate exits non-zero when a blocker is found', () => { " process.stderr.write('npm error code ETARGET\\n'); process.exit(1)\n" + ' }\n' + " const dest = process.argv[process.argv.indexOf('--pack-destination') + 1]\n" + + // A `files` artefact (@cipherstash/auth): the tarball carries the + // tree's own bytes for every listed file, so CHECK C compares them for + // real and passes. + ' const files = JSON.parse(process.env.FAKE_NPM_FILES)[name]\n' + + ' if (files) {\n' + + ' for (const [published, source] of Object.entries(files)) {\n' + + " const target = path.join(dest, 'stage', published)\n" + + ' fs.mkdirSync(path.dirname(target), { recursive: true })\n' + + ' fs.copyFileSync(source, target)\n' + + ' }\n' + + " require('node:child_process').execFileSync('tar', ['-czf', path.join(dest, 'f.tgz'), '-C', path.join(dest, 'stage'), 'package'])\n" + + " process.stdout.write('f.tgz\\n'); process.exit(0)\n" + + ' }\n' + " const stage = path.join(dest, 'stage', 'package', 'dist', 'sql')\n" + ' fs.mkdirSync(stage, { recursive: true })\n' + " fs.writeFileSync(path.join(stage, 'release-manifest.json'), JSON.stringify({\n" + @@ -825,6 +841,21 @@ describe('the gate exits non-zero when a blocker is found', () => { return { dir, versions, packDigest } } + /** For each `files` artefact, the tree file behind each tarball member. */ + const FILES_ARTEFACTS = Object.fromEntries( + [...FROZEN_ARTEFACT_DIGESTS] + .filter(([, artefact]) => artefact.files) + .map(([name, artefact]) => [ + name, + Object.fromEntries( + artefact.files.map((file) => [ + file.published, + join(REPO_ROOT, file.inTree), + ]), + ), + ]), + ) + /** Every workspace package published at exactly its committed version. */ const allPublished = Object.fromEntries( manifests.map(({ name, version }) => [name, [version]]), @@ -850,6 +881,7 @@ describe('the gate exits non-zero when a blocker is found', () => { PATH: `${dir}:${process.env.PATH}`, FAKE_NPM_VERSIONS: versions, FAKE_NPM_PACK_DIGEST: packDigest, + FAKE_NPM_FILES: JSON.stringify(FILES_ARTEFACTS), // The real one would be written for the whole vitest run. GITHUB_OUTPUT: join(dir, 'github-output.txt'), }, @@ -933,6 +965,20 @@ describe('the gate exits non-zero when a blocker is found', () => { expect(result.status).toBe(0) expect(result.stdout).toContain(`unpublished: ${EQL}`) }) + + it('blocks a stray @cipherstash/auth bump while the auth packages are frozen', () => { + // The auth freeze, end to end, through the real script and the real maps: + // npm carries the committed 0.44.0 and not the bump, so CHECK A names the + // bumped version and the release stops. + const result = runGate( + { ...allPublished, [AUTH]: ['0.43.0'] }, + IN_TREE_DIGEST, + REAL_SCRIPT, + ) + expect(result.status).toBe(1) + expect(result.stderr).toContain(`${AUTH}@0.44.0 is not on npm`) + expect(result.stderr).toContain('cipherstash/cipherstash-suite') + }) }) describe('frozenBytesSkew', () => { @@ -1277,8 +1323,21 @@ describe('inTreeArtefactDigest, over the real committed manifest', () => { }) it('resolves every artefact the real map declares', () => { + // A `field` artefact resolves to one digest, a `files` artefact to one + // `<published path> <sha256>` line per listed file. A `noTreeBytes` entry + // declares that the tree holds nothing to read; CHECK C skips it, and + // 'a `noTreeBytes` artefact' below holds it to a reason. for (const [name, artefact] of FROZEN_ARTEFACT_DIGESTS) { - expect(inTreeArtefactDigest(name, artefact)).toMatch(/^[0-9a-f]{64}$/) + if ('noTreeBytes' in artefact) continue + const digest = inTreeArtefactDigest(name, artefact) + if (artefact.files) { + const lines = digest.split('\n') + expect(lines, name).toHaveLength(artefact.files.length) + for (const line of lines) + expect(line, name).toMatch(/^package\/\S+ [0-9a-f]{64}$/) + } else { + expect(digest, name).toMatch(/^[0-9a-f]{64}$/) + } } }) @@ -1329,3 +1388,169 @@ describe('reportBlockers, for a bytes skew', () => { expect(text).not.toMatch(/Publish the frozen package\./) }) }) + +/** + * The `files` artefact shape, which `@cipherstash/auth` needs because it has no + * release manifest to read a digest from. The gate hashes each listed file on + * both sides; EQL's `field` entry keeps working unchanged (every EQL test + * above). + */ +describe('a `files` artefact', () => { + const AUTH_DIR = 'languages/typescript/packages/auth' + const artefact = FROZEN_ARTEFACT_DIGESTS.get(AUTH) + + it('hashes every listed file in the tree, one line per file', () => { + const lines = inTreeArtefactDigest(AUTH, artefact).split('\n') + expect(lines).toHaveLength(artefact.files.length) + for (const line of lines) + expect(line).toMatch(/^package\/\S+ [0-9a-f]{64}$/) + }) + + it('lists every tracked file the wrapper publishes, except package.json', () => { + // A file the wrapper publishes and the list leaves out is a file whose + // bytes nobody compares. `files` in package.json is what npm packs, and + // `wasm/` is a build output with nothing tracked behind it. + const manifest = JSON.parse( + readFileSync(join(REPO_ROOT, AUTH_DIR, 'package.json'), 'utf8'), + ) + const published = manifest.files.filter((entry) => !entry.endsWith('/')) + expect(artefact.files.map((file) => file.inTree).sort()).toEqual( + published.map((file) => `${AUTH_DIR}/${file}`).sort(), + ) + expect(manifest.files.filter((entry) => entry.endsWith('/'))).toEqual([ + 'wasm/', + ]) + }) + + it('throws, naming the file, when a listed file is missing from the tree', () => { + expect(() => + inTreeArtefactDigest(AUTH, { + files: [ + { inTree: `${AUTH_DIR}/no-such-file.js`, published: 'package/x.js' }, + ], + }), + ).toThrow(/no-such-file\.js/) + }) + + it('names only the file that differs', () => { + const [blocker] = frozenBytesSkew({ + manifests: [{ name: AUTH, version: '0.44.0', private: false }], + frozen: new Map([[AUTH, 'frozen']]), + artefacts: new Map([[AUTH, artefact]]), + inTreeDigest: () => 'package/a.js 1111\npackage/b.js 2222', + publishedDigest: () => 'package/a.js 1111\npackage/b.js 3333', + }) + expect(blocker.kind).toBe('frozen-bytes-skew') + expect(blocker.local).toBe('package/b.js 2222') + expect(blocker.published).toBe('package/b.js 3333') + }) + + it('reads the same bytes out of a tarball as from the tree', () => { + // Driven through the real `npm pack` + `tar` path with a shimmed `npm` + // that packs the tree's own files, so the two digests must agree. + const dir = mkdtempSync(join(tmpdir(), 'release-gate-files-')) + const shim = join(dir, 'npm') + const members = Object.fromEntries( + artefact.files.map((file) => [ + file.published, + join(REPO_ROOT, file.inTree), + ]), + ) + writeFileSync( + shim, + '#!/usr/bin/env node\n' + + "const fs = require('node:fs'), path = require('node:path')\n" + + "const dest = process.argv[process.argv.indexOf('--pack-destination') + 1]\n" + + `const members = ${JSON.stringify(members)}\n` + + 'const drop = process.env.DROP_MEMBER\n' + + 'for (const [published, source] of Object.entries(members)) {\n' + + ' if (published === drop) continue\n' + + " const target = path.join(dest, 'stage', published)\n" + + ' fs.mkdirSync(path.dirname(target), { recursive: true })\n' + + ' fs.copyFileSync(source, target)\n' + + '}\n' + + "require('node:child_process').execFileSync('tar', ['-czf', path.join(dest, 'f.tgz'), '-C', path.join(dest, 'stage'), 'package'])\n" + + "process.stdout.write('f.tgz\\n')\n", + ) + chmodSync(shim, 0o755) + const path = process.env.PATH + process.env.PATH = `${dir}:${path}` + try { + expect(publishedArtefactDigest(AUTH, '0.44.0', artefact)).toBe( + inTreeArtefactDigest(AUTH, artefact), + ) + // A listed file missing from the tarball throws rather than passing. + process.env.DROP_MEMBER = 'package/next.mjs' + expect(() => publishedArtefactDigest(AUTH, '0.44.0', artefact)).toThrow( + /package\/next\.mjs/, + ) + } finally { + process.env.PATH = path + delete process.env.DROP_MEMBER + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +/** + * The `noTreeBytes` shape: the six @cipherstash/auth platform packages publish + * only a binary built in CI, so CHECK C has nothing to compare and skips them. + * They are frozen all the same, so CHECK A blocks a stray version. + */ +describe('a `noTreeBytes` artefact', () => { + const platforms = workspaceManifests() + .map((manifest) => manifest.name) + .filter((name) => name.startsWith(`${AUTH}-`)) + + it('covers every @cipherstash/auth platform package in the workspace', () => { + expect(platforms).toHaveLength(6) + for (const name of platforms) { + expect(FROZEN_PUBLISHERS.has(name), name).toBe(true) + expect(FROZEN_ARTEFACT_DIGESTS.get(name).noTreeBytes, name).toMatch(/\S/) + } + }) + + it('is skipped by CHECK C without asking the registry', () => { + const name = platforms[0] + expect( + frozenBytesSkew({ + manifests: [{ name, version: '0.44.0', private: false }], + frozen: new Map([[name, 'frozen']]), + artefacts: new Map([[name, FROZEN_ARTEFACT_DIGESTS.get(name)]]), + inTreeDigest: () => { + throw new Error('must not read the tree') + }, + publishedDigest: () => { + throw new Error('must not download') + }, + }), + ).toEqual([]) + }) + + it('throws when the reason is empty', () => { + const name = platforms[0] + expect(() => + frozenBytesSkew({ + manifests: [{ name, version: '0.44.0', private: false }], + frozen: new Map([[name, 'frozen']]), + artefacts: new Map([ + [name, { label: 'platform binary', noTreeBytes: '' }], + ]), + inTreeDigest: () => 'x', + publishedDigest: () => 'x', + }), + ).toThrow(/noTreeBytes/) + }) + + it('still blocks a platform version npm does not carry (CHECK A)', () => { + const name = platforms[0] + expect( + publishBlockers({ + manifests: [{ name, version: '0.44.1', private: false }], + lookup: () => ['0.44.0'], + }).map( + (blocker) => `${blocker.kind} ${blocker.package}@${blocker.version}`, + ), + ).toEqual([`frozen-publisher ${name}@0.44.1`]) + }) +}) diff --git a/scripts/__tests__/wasi-check-gate.test.mjs b/scripts/__tests__/wasi-check-gate.test.mjs new file mode 100644 index 000000000..1bc30dcfb --- /dev/null +++ b/scripts/__tests__/wasi-check-gate.test.mjs @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' + +/** + * `wasm:wasi-check` greps `cargo tree` output for crates that must never link + * into a WASI guest. CI sets `CARGO_TERM_COLOR=always`, and cargo then wraps + * each line's tree prefix in ANSI colour codes. The grep anchors on that + * prefix, so with colour on it can never match: the gate passed with + * `wasm-bindgen` added to stack-kms in a deliberate-break run on 2 October + * 2026, while the same break failed it locally, where output is plain. + */ + +const miseToml = readFileSync(join(REPO_ROOT, 'mise.toml'), 'utf8') + +function taskRun(name) { + const start = miseToml.indexOf(`[tasks."${name}"]`) + expect(start, `${name} task`).toBeGreaterThan(-1) + const open = miseToml.indexOf('run = """', start) + const close = miseToml.indexOf('"""', open + 'run = """'.length) + return miseToml.slice(open, close) +} + +const run = taskRun('wasm:wasi-check') + +describe('wasm:wasi-check dependency gate', () => { + it('asks cargo tree for uncoloured output', () => { + const calls = run.match(/^(?!\s*#).*cargo tree[^\n]*/gm) ?? [] + expect(calls.length).toBeGreaterThan(0) + for (const call of calls) expect(call).toContain('--color never') + }) + + it('its patterns match a forbidden crate in a plain tree, and not in a coloured one', () => { + const patterns = [...run.matchAll(/grep -Eq '([^']+)'/g)].map( + ([, pattern]) => new RegExp(pattern, 'm'), + ) + expect(patterns.length).toBe(2) + const plain = 'stack-kms v0.1.0\n├── wasm-bindgen v0.2.100\n' + const coloured = + 'stack-kms v0.1.0\n\u001b[2m├── \u001b[0mwasm-bindgen v0.2.100\n' + expect(patterns[0].test(plain)).toBe(true) + // Documents why `--color never` is load-bearing. + expect(patterns[0].test(coloured)).toBe(false) + }) +}) diff --git a/scripts/__tests__/workflow-mise-setup.test.mjs b/scripts/__tests__/workflow-mise-setup.test.mjs index 7e1629b5a..d19d330b0 100644 --- a/scripts/__tests__/workflow-mise-setup.test.mjs +++ b/scripts/__tests__/workflow-mise-setup.test.mjs @@ -332,14 +332,18 @@ describe('the mise setup step carries the inputs it depends on', () => { it('points every mise setup step at a directory that has a mise config', () => { // THE TRUST ERROR. mise reads config from the current directory and its - // PARENTS, and this repo has NO mise config at its root — the two that - // exist are `packages/eql/mise.toml` and `languages/typescript/packages/protect-ffi/mise.toml`. - // So an action running at the default working directory finds nothing to - // install and leaves the config untrusted, and the first `mise run` fails - // with "Config files … are not trusted", which reads as a broken toolchain - // rather than a wrong directory. In `release.yml` that file is also where - // the Rust toolchain comes from (`[tools] rust`), so the same input is what - // makes the step a cargo setup; there is deliberately no second one. + // PARENTS. The repo has three mise configs: the root `mise.toml` (the + // stack-* crates and the Go module), `packages/eql/mise.toml` and + // `languages/typescript/packages/protect-ffi/mise.toml`. A job pointed at + // the wrong one installs the wrong tools and leaves the config it needs + // untrusted, and the first `mise run` fails with "Config files … are not + // trusted", which reads as a broken toolchain rather than a wrong + // directory. So every step names its directory, even a root job + // (`working_directory: .`): with a root config, an unset directory would + // now find the ROOT config and quietly pass an EQL job that forgot + // `packages/eql`. In `release.yml` the named file is also where the Rust + // toolchain comes from (`[tools] rust`), so the same input is what makes + // the step a cargo setup; there is deliberately no second one. const offenders = MISE_STEPS.filter(({ inputs }) => { const dir = inputs?.working_directory if (typeof dir !== 'string' || dir.trim() === '') return true @@ -353,7 +357,7 @@ describe('the mise setup step carries the inputs it depends on', () => { expect( offenders, - 'These `jdx/mise-action` steps do not name a directory containing a mise config. There is no mise config at the repo root, so mise installs nothing and marks the config untrusted — the first `mise run` then fails with a TRUST error that looks like a toolchain problem.', + 'These `jdx/mise-action` steps do not name a directory containing a mise config. Set `working_directory:` explicitly — `.` for the root `mise.toml` (the stack-* crates and Go), `packages/eql` or `languages/typescript/packages/protect-ffi` for theirs. Without it mise reads whichever config the default directory reaches, installs the wrong tools, and leaves the one the job needs untrusted — the first `mise run` then fails with a TRUST error that looks like a toolchain problem.', ).toEqual([]) }) diff --git a/scripts/check-auth-npm-changeset.mjs b/scripts/check-auth-npm-changeset.mjs new file mode 100644 index 000000000..97a87e699 --- /dev/null +++ b/scripts/check-auth-npm-changeset.mjs @@ -0,0 +1,68 @@ +import fs from 'node:fs' +import parseChangeset from '@changesets/parse' + +const changesetFiles = process.argv.slice(2).filter(Boolean) + +if (changesetFiles.length === 0) { + console.error( + "::error::Release-relevant stack-auth changes require an @cipherstash/auth changeset. Run 'npx changeset' and commit the generated file.", + ) + process.exit(1) +} + +let hasAuthRelease = false + +for (const changesetFile of changesetFiles) { + const contents = fs.readFileSync(changesetFile, 'utf8') + const lines = contents.split(/\r?\n/) + const closingDelimiter = lines.indexOf('---', 1) + + if (lines[0] !== '---' || closingDelimiter === -1) { + console.error( + `::error file=${changesetFile}::Changeset must start with YAML frontmatter delimited by ---`, + ) + process.exit(1) + } + + if ( + lines + .slice(closingDelimiter + 1) + .join('\n') + .trim().length === 0 + ) { + console.error( + `::error file=${changesetFile}::Changeset summary must not be empty`, + ) + process.exit(1) + } + + let changeset + try { + changeset = parseChangeset(contents) + } catch (error) { + console.error( + `::error file=${changesetFile}::Invalid changeset: ${error.message}`, + ) + process.exit(1) + } + + if ( + changeset.releases.some( + ({ name, type }) => + name === '@cipherstash/auth' && + (type === 'patch' || type === 'minor' || type === 'major'), + ) + ) { + hasAuthRelease = true + console.log( + `Found valid @cipherstash/auth release intent in ${changesetFile}`, + ) + } +} + +if (!hasAuthRelease) { + console.error( + "::error::Release-relevant stack-auth changes require an @cipherstash/auth changeset with a patch, minor, or major bump. Run 'npx changeset' and commit the generated file.", + ) + process.exit(1) +} diff --git a/scripts/check-wasm-imports.py b/scripts/check-wasm-imports.py new file mode 100755 index 000000000..86e538fd0 --- /dev/null +++ b/scripts/check-wasm-imports.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Assert a .wasm module's import surface is exactly what we promise. + +The Go/wazero binding's security contract is that the guest can only reach +the outside world through the host functions we hand it: no ambient network +or filesystem access sneaking in through a dependency. That is a property of +the *linked module*, so it can only be checked on the built artifact — a +`cargo build` that succeeds proves nothing about it. + +Fail-closed by construction: an import is rejected unless its module was +explicitly allowed (--allow-module) or the exact `module:name` pair was +required (--require), and every required pair must be present. A new +dependency that pulls in an extra host import therefore fails the build +rather than silently widening the surface. + +`--deny-prefix` narrows an allowed module from within: WASI is one module +name covering both harmless calls (`random_get`, `fd_write` on stdio) and +the capability-granting ones (`path_open`, `sock_*`), so the ambient-access +half is denied by name even though the module is allowed. + +Usage: + check-wasm-imports.py MODULE.wasm --allow-module wasi_snapshot_preview1 \\ + --deny-prefix wasi_snapshot_preview1:path_ \\ + --require cipherstash_transport:transport_send +""" + +import argparse +import sys + + +class MalformedModule(Exception): + pass + + +class Reader: + """Minimal cursor over a wasm binary (little-endian, LEB128 varuints).""" + + def __init__(self, data: bytes): + self.data = data + self.pos = 0 + + def bytes(self, n: int) -> bytes: + if n < 0 or self.pos + n > len(self.data): + raise MalformedModule("truncated module") + out = self.data[self.pos : self.pos + n] + self.pos += n + return out + + def byte(self) -> int: + return self.bytes(1)[0] + + def varuint(self) -> int: + result = 0 + shift = 0 + while True: + if shift > 63: + raise MalformedModule("LEB128 value too large") + b = self.byte() + result |= (b & 0x7F) << shift + if not b & 0x80: + return result + shift += 7 + + def name(self) -> str: + raw = self.bytes(self.varuint()) + try: + return raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise MalformedModule("import name is not valid UTF-8") from exc + + +def skip_limits(reader: Reader) -> None: + flags = reader.byte() + reader.varuint() # minimum + if flags & 0x01: + reader.varuint() # maximum + + +def skip_import_descriptor(reader: Reader) -> None: + kind = reader.byte() + if kind == 0x00: # func: type index + reader.varuint() + elif kind == 0x01: # table: reftype, limits + reader.byte() + skip_limits(reader) + elif kind == 0x02: # memory: limits + skip_limits(reader) + elif kind == 0x03: # global: valtype, mutability + reader.byte() + reader.byte() + else: + raise MalformedModule(f"unknown import kind 0x{kind:02x}") + + +def read_imports(data: bytes) -> list: + """Every (module, name) pair in the module's import section.""" + reader = Reader(data) + if reader.bytes(4) != b"\x00asm": + raise MalformedModule("not a wasm module (bad magic)") + version = int.from_bytes(reader.bytes(4), "little") + if version != 1: + raise MalformedModule(f"unsupported wasm version {version}") + + imports = [] + seen_import_section = False + while reader.pos < len(data): + section_id = reader.byte() + size = reader.varuint() + end = reader.pos + size + if end > len(data): + raise MalformedModule("section runs past end of module") + if section_id == 2: # import section + if seen_import_section: + raise MalformedModule("duplicate import section") + seen_import_section = True + for _ in range(reader.varuint()): + module = reader.name() + field = reader.name() + skip_import_descriptor(reader) + imports.append((module, field)) + if reader.pos != end: + raise MalformedModule("import section size mismatch") + reader.pos = end + return imports + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("module", help="path to the .wasm file") + parser.add_argument( + "--allow-module", + action="append", + default=[], + metavar="MODULE", + help="import module whose functions are all permitted (repeatable)", + ) + parser.add_argument( + "--deny-prefix", + action="append", + default=[], + metavar="MODULE:PREFIX", + help="reject imports from an allowed module whose name starts with " + "PREFIX (repeatable)", + ) + parser.add_argument( + "--require", + action="append", + default=[], + metavar="MODULE:NAME", + help="import that must be present, and is permitted (repeatable)", + ) + args = parser.parse_args() + + required = set() + for spec in args.require: + module, sep, name = spec.partition(":") + if not sep or not module or not name: + parser.error(f"--require expects MODULE:NAME, got {spec!r}") + required.add((module, name)) + allowed_modules = set(args.allow_module) + + denied_prefixes = [] + for spec in args.deny_prefix: + module, sep, prefix = spec.partition(":") + if not sep or not module or not prefix: + parser.error(f"--deny-prefix expects MODULE:PREFIX, got {spec!r}") + denied_prefixes.append((module, prefix)) + + try: + with open(args.module, "rb") as f: + data = f.read() + imports = read_imports(data) + except OSError as exc: + print(f"error: cannot read {args.module}: {exc}", file=sys.stderr) + return 1 + except MalformedModule as exc: + print(f"error: {args.module}: {exc}", file=sys.stderr) + return 1 + + def denied(imp): + return any( + imp[0] == module and imp[1].startswith(prefix) + for module, prefix in denied_prefixes + ) + + unexpected = [ + imp + for imp in imports + if denied(imp) or (imp[0] not in allowed_modules and imp not in required) + ] + missing = sorted(required - set(imports)) + + for module, name in sorted(unexpected): + print(f"error: unexpected host import {module}::{name}", file=sys.stderr) + for module, name in missing: + print(f"error: required host import {module}::{name} is absent", file=sys.stderr) + if unexpected or missing: + print( + "error: import surface of " + f"{args.module} is not the one the host contract promises", + file=sys.stderr, + ) + return 1 + + print(f"import surface OK: {len(imports)} imports, all expected") + for module, name in sorted(set(imports)): + print(f" {module}::{name}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/go-binding-test.sh b/scripts/go-binding-test.sh new file mode 100755 index 000000000..0b9bd0db2 --- /dev/null +++ b/scripts/go-binding-test.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Format check, vet and test one Go binding module against its embedded guest. +# +# One definition of "the Go binding passes", run on three platforms: the mise +# task `go:test` (Linux CI, and locally) and the macOS/Windows +# jobs in .github/workflows/test-wasi.yml both call this, so they cannot +# drift apart. The guest module itself is built once, on Linux, and handed to +# the other platforms as an artifact — the wasm is platform-independent and +# the Rust build is the slow part. +# +# Usage: go-binding-test.sh <module dir> [<guest path, relative to it>...] +# With no guest paths, both guests the module embeds are expected. +set -euo pipefail + +dir=${1:?usage: go-binding-test.sh <module dir> [<guest path>...]} +shift || true +if [ $# -eq 0 ]; then + set -- stackencrypt/wasm/stack_encrypt_guest.wasm stackauth/wasm/stack_auth_guest.wasm +fi + +cd "$dir" +for guest in "$@"; do + if [ ! -f "$guest" ]; then + echo "guest module not built at $dir/$guest — run: mise run wasm:guest:build wasm:auth-guest:build" >&2 + exit 1 + fi +done + +# gofmt exits 0 even when files need formatting; -l lists them. +out=$(gofmt -l .) +if [ -n "$out" ]; then + echo "gofmt needed:" + echo "$out" + exit 1 +fi + +go vet ./... +# Keep the refresh-lock results explicit in CI logs on Linux, macOS, and +# Windows. These tests exercise the platform lock implementation with two +# independent guest instances sharing one auth.json. +CGO_ENABLED=0 go test -v ./stackauth -run '^TestDeviceRefresh' +CGO_ENABLED=0 go test ./... + +# The transport codec's u32-bound guards are load-bearing where int is 32 +# bits (vitaminc's vcffi runs this sweep too); the binding's own reflection +# and buffer arithmetic must hold there as well. Linux hosts execute 386 +# natively; elsewhere the binary cannot run, so only vet. +if [ "$(uname -s)" = "Linux" ]; then + CGO_ENABLED=0 GOOS=linux GOARCH=386 go test ./... +else + CGO_ENABLED=0 GOOS=linux GOARCH=386 go vet ./... +fi diff --git a/scripts/lint-no-auth-changeset.mjs b/scripts/lint-no-auth-changeset.mjs new file mode 100644 index 000000000..fd3d35665 --- /dev/null +++ b/scripts/lint-no-auth-changeset.mjs @@ -0,0 +1,78 @@ +/** + * Fail if any pending changeset names one of the seven `@cipherstash/auth` + * packages. + * + * TEMPORARY. Delete this script, its self-test and its `lint:auth-changeset` + * entry in the arming PR of the stack-* crates import (PR E), when npm trusted + * publishing for the seven packages moves from `cipherstash/cipherstash-suite` + * to `cipherstash/stack` and their `FROZEN_PUBLISHERS` entries are deleted. + * + * The release gate's CHECK A already refuses to publish a frozen package at a + * version npm does not carry, but it fires on `main` after the Version + * Packages PR merges, and then it blocks every release until the bump is + * reverted. This stops the changeset on the pull request instead. + * + * There is nowhere to park the changeset: the `.md.deferred` convention the + * retired protect-ffi guard used is forbidden by `no-parked-changesets`. + */ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' + +const REPO_ROOT = resolve(import.meta.dirname, '..') + +const GUARDED = new Set([ + '@cipherstash/auth', + '@cipherstash/auth-darwin-arm64', + '@cipherstash/auth-darwin-x64', + '@cipherstash/auth-linux-arm64-gnu', + '@cipherstash/auth-linux-x64-gnu', + '@cipherstash/auth-linux-x64-musl', + '@cipherstash/auth-win32-x64-msvc', +]) + +const changesetDir = process.argv[2] + ? resolve(process.argv[2]) + : join(REPO_ROOT, '.changeset') + +// Only the first fenced block is frontmatter: prose below it may quote a +// package name, and `---` rules in markdown would otherwise reopen the block. +function packagesIn(source) { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source) + if (!match) return [] + return match[1] + .split(/\r?\n/) + .map((line) => /^\s*['"]?(@?[^'":]+?)['"]?\s*:/.exec(line)) + .filter(Boolean) + .map((m) => m[1].trim()) +} + +const offenders = [] +for (const entry of readdirSync(changesetDir)) { + if (!entry.endsWith('.md') || entry === 'README.md') continue + const named = packagesIn(readFileSync(join(changesetDir, entry), 'utf8')) + const guarded = named.filter((name) => GUARDED.has(name)) + if (guarded.length) offenders.push({ file: entry, packages: guarded }) +} + +if (offenders.length === 0) { + console.log('No pending changeset names an @cipherstash/auth package.') + process.exit(0) +} + +console.error('\nA pending changeset names an @cipherstash/auth package:\n') +for (const { file, packages } of offenders) { + console.error(` ${relative(REPO_ROOT, join(changesetDir, file))}`) + for (const name of packages) console.error(` ${name}`) +} +console.error( + '\nThese seven packages live in this repo but are still PUBLISHED from\n' + + 'cipherstash/cipherstash-suite — npm trusted publishing has not been\n' + + 'repointed yet, and this repository has no job that builds their native\n' + + 'binaries. Releasing a bumped version from here is blocked by\n' + + '`release:gate`, and that block stops every other release with it.\n\n' + + 'Remove the changeset. Merges that touch the auth packages are paused\n' + + 'until the arming PR (PR E of the stack-* crates import), which repoints\n' + + 'trusted publishing, deletes this script and writes the changesets. If a\n' + + 'change must land before then, put its release note in the pull request.\n', +) +process.exit(1) diff --git a/scripts/lint-typecheck-scope.mjs b/scripts/lint-typecheck-scope.mjs index 326d5a6c0..4137a62a1 100644 --- a/scripts/lint-typecheck-scope.mjs +++ b/scripts/lint-typecheck-scope.mjs @@ -31,12 +31,14 @@ const REPO_ROOT = resolve(import.meta.dirname, '..') // with argv[2..] for tests / ad-hoc checks (each arg is a package directory). // // NESTED roots are listed separately because the walk below is one level deep, -// matching how pnpm globs `languages/typescript/packages/*`. Three sets of +// matching how pnpm globs `languages/typescript/packages/*`. Five sets of // packages sit a level further down and need their own entry here for the same // reason they need one in `pnpm-workspace.yaml`: // // languages/typescript/packages/protect-ffi/platforms/* the six per-platform binary packages // packages/eql/packages/* @cipherstash/eql, from the EQL subtree +// languages/typescript/packages/auth/platforms/* the @cipherstash/auth platform packages +// languages/typescript/packages/profile/platforms/* the @cipherstash/profile platform packages // languages/typescript/packages/protect-ffi/* the live integration suite // // The last is spelled as its PARENT rather than as the member, because the walk @@ -55,6 +57,10 @@ const WORKSPACE_ROOTS = [ 'packages', 'languages/typescript/packages/protect-ffi', 'languages/typescript/packages/protect-ffi/platforms', + // The node bindings imported from cipherstash-suite. `auth` and `profile` + // themselves are one level down and found through the first root. + 'languages/typescript/packages/auth/platforms', + 'languages/typescript/packages/profile/platforms', 'packages/eql/packages', ] diff --git a/scripts/release-gate.mjs b/scripts/release-gate.mjs index 52e031c71..b00e89d2b 100644 --- a/scripts/release-gate.mjs +++ b/scripts/release-gate.mjs @@ -35,6 +35,7 @@ * pointing at a version nobody can install. */ import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { appendFileSync, globSync, @@ -127,11 +128,12 @@ const INSTALLED_TABLES = new Set([ * `scripts/lint-no-eql-registry-pins.mjs`. * * Each entry is DELETED by the cutover that repoints its publisher. - * `@cipherstash/eql` was the last one: its Phase-5 release cutover repointed - * npm and crates.io trusted publishing at this repository and deleted its - * entry here, which is why the map is empty. Empty is a legitimate state, not a - * retired mechanism — the next package that lives here before its publisher - * moves goes back in, with its artefact below. + * `@cipherstash/eql`'s Phase-5 release cutover repointed npm and crates.io + * trusted publishing at this repository and deleted its entry here. The + * `@cipherstash/auth` packages below are in the same position until the + * arming PR of the stack-* crates import. An empty map is a legitimate state, + * not a retired mechanism — the next package that lives here before its + * publisher moves goes in, with its artefact below. * * DELETE IT IN THAT PR, not afterwards. An entry left behind does not fail on * the day it goes wrong, it fails on the next release: while the package sits @@ -143,7 +145,27 @@ const INSTALLED_TABLES = new Set([ * `release-gate.test.mjs` now asserts their absence, so the map has a test for * what is NOT in it as well as what is. */ -export const FROZEN_PUBLISHERS = new Map([]) +export const FROZEN_PUBLISHERS = new Map([ + // The @cipherstash/auth wrapper and its six platform packages, imported + // from cipherstash-suite with the stack-* crates. They keep publishing from + // there until the arming PR of that import repoints npm trusted publishing + // at this repository and deletes all seven entries here, in both maps. + ...[ + '@cipherstash/auth', + '@cipherstash/auth-darwin-arm64', + '@cipherstash/auth-darwin-x64', + '@cipherstash/auth-linux-arm64-gnu', + '@cipherstash/auth-linux-x64-gnu', + '@cipherstash/auth-linux-x64-musl', + '@cipherstash/auth-win32-x64-msvc', + ].map((name) => [ + name, + 'Still published from cipherstash/cipherstash-suite — npm trusted publishing ' + + 'for the seven @cipherstash/auth packages names that repository, and ' + + '`release.yml` here has no job that builds the native binaries. Repointing ' + + 'is the arming PR (PR E) of the stack-* crates import.', + ]), +]) /** * For each frozen package, the artefact whose bytes must equal the published @@ -165,8 +187,8 @@ export const FROZEN_PUBLISHERS = new Map([]) * reads that SQL verbatim (`readInstallSql`, no digest check), so * `stash eql install` would have put functions into a customer database that * the version it reports does not define. It was caught by a human reading the - * diff. `release-gate.test.mjs` keeps that entry as a fixture, so the check is - * still driven over the real tree while the map is empty. + * diff. `release-gate.test.mjs` keeps that entry as a fixture, so the `field` + * check is still driven over the real tree although no entry here uses it. * * The digest is read from each side's release manifest rather than hashed * here: the manifest is the artefact's own statement about itself, so a @@ -178,8 +200,66 @@ export const FROZEN_PUBLISHERS = new Map([]) * that by equality, so a frozen publisher added without an artefact fails * rather than silently getting no bytes check. Both entries are DELETED * together by the cutover that repoints the publisher. + * + * ## Three shapes of entry + * + * * `field` — a digest read out of a release manifest on each side, as for + * `@cipherstash/eql` while it was frozen (the fixture in + * `release-gate.test.mjs`). + * * `files` — for a package with no such manifest. The gate hashes each + * listed file with sha256, in the tree and in the published tarball, and + * a mismatch names the file. `@cipherstash/auth` is this shape: the list + * is every tracked file the wrapper publishes except `package.json`, which + * publishing rewrites. `wasm/` is a build output and is not listed. The + * list cannot see the Rust source, because the compiled binary is not in + * the tree — the freeze covers the JavaScript and type surface only. + * * `noTreeBytes` — a package whose tarball holds nothing the tree has, only + * a binary built in CI. CHECK C skips it, and the string says why. The + * entry still exists so the key-equality test holds, and CHECK A still + * blocks a version npm does not carry. */ -export const FROZEN_ARTEFACT_DIGESTS = new Map([]) +export const FROZEN_ARTEFACT_DIGESTS = new Map([ + [ + '@cipherstash/auth', + { + label: 'wrapper sources and type declarations', + files: [ + 'index.js', + 'stack-auth-node.js', + 'wasm-inline.mjs', + 'cookies.mjs', + 'base64url.mjs', + 'next.mjs', + 'index.d.ts', + 'native.d.ts', + 'wasm-types.d.ts', + 'wasm-inline.d.ts', + 'cookies.d.ts', + 'base64url.d.ts', + 'next.d.ts', + 'README.md', + 'LICENSE', + ].map((file) => ({ + inTree: `languages/typescript/packages/auth/${file}`, + published: `package/${file}`, + })), + }, + ], + ...[ + '@cipherstash/auth-darwin-arm64', + '@cipherstash/auth-darwin-x64', + '@cipherstash/auth-linux-arm64-gnu', + '@cipherstash/auth-linux-x64-gnu', + '@cipherstash/auth-linux-x64-musl', + '@cipherstash/auth-win32-x64-msvc', + ].map((name) => [ + name, + { + label: 'platform binary', + noTreeBytes: 'the published tarball holds only a binary built in CI', + }, + ]), +]) /** * The range pnpm writes into the packed `package.json` for a `workspace:` @@ -476,6 +556,21 @@ export function frozenBytesSkew({ ) } + // Nothing in the tree to compare. An empty reason is a declaration that + // has lost its justification, so it throws like a missing artefact. + if ('noTreeBytes' in artefact) { + if ( + typeof artefact.noTreeBytes !== 'string' || + artefact.noTreeBytes.trim() === '' + ) { + throw new Error( + `${name} declares \`noTreeBytes\` with no reason. Say why the tarball ` + + 'holds nothing the tree has, or declare a `field` or `files` artefact.', + ) + } + continue + } + const published = publishedDigest(name, version, artefact) if (published === null || published === undefined) continue @@ -486,14 +581,40 @@ export function frozenBytesSkew({ package: name, version, label: artefact.label, - local, - published, + ...differingLines(local, published), }) } } return blockers } +/** + * For a `files` artefact, only the files that differ. + * + * Its digest is one `<published path> <sha256>` line per file, so the lines + * that differ are the files that differ, and the report names them rather + * than printing fifteen hashes. A `field` digest is a single line, and passes + * through unchanged. + */ +function differingLines(local, published) { + const left = String(local).split('\n') + const right = String(published).split('\n') + if (left.length === 1 && right.length === 1) return { local, published } + const only = (lines, other) => + lines.filter((line) => !other.includes(line)).join('\n ') + return { local: only(left, right), published: only(right, left) } +} + +/** One `<published path> <sha256>` line per listed file. */ +function fileDigests(files, read) { + return files + .map(({ published }) => { + const hash = createHash('sha256').update(read(published)).digest('hex') + return `${published} ${hash}` + }) + .join('\n') +} + /** * The `packages:` globs from `pnpm-workspace.yaml`, parsed without a YAML * library. @@ -633,8 +754,31 @@ function digestField(manifest, artefact, source) { return value } -/** The digest an artefact's IN-TREE release manifest claims for itself. */ +/** + * The digest an artefact's IN-TREE release manifest claims for itself — or, + * for a `files` artefact, the sha256 of each listed file on disk. + * + * A listed file that is missing THROWS, naming it: a stale list must not pass + * quietly, for the same reason a missing field throws. + */ export function inTreeArtefactDigest(_name, artefact) { + if (artefact.files) { + const byPublished = new Map( + artefact.files.map((file) => [file.published, file.inTree]), + ) + return fileDigests(artefact.files, (published) => { + const inTree = byPublished.get(published) + try { + return readFileSync(join(REPO_ROOT, inTree)) + } catch (err) { + throw new Error( + `${inTree} is listed in FROZEN_ARTEFACT_DIGESTS but cannot be read ` + + `(${err.code ?? err.message}). Fix the list rather than leaving the ` + + 'check disarmed.', + ) + } + }) + } const path = join(REPO_ROOT, artefact.inTree) return digestField( JSON.parse(readFileSync(path, 'utf8')), @@ -700,12 +844,15 @@ export function publishedArtefactDigest(name, version, artefact) { ) } + // One member for a `field` artefact, every listed file for a `files` one. + // A member missing from the tarball fails the extraction, which names it. + const members = artefact.files + ? artefact.files.map((file) => file.published) + : [artefact.published] try { - execFileSync( - 'tar', - ['-xzf', join(dir, packed), '-C', dir, artefact.published], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ) + execFileSync('tar', ['-xzf', join(dir, packed), '-C', dir, ...members], { + stdio: ['ignore', 'pipe', 'pipe'], + }) } catch (err) { // Already fail-closed — this call sits outside the E404 catch above — but // the message was `Command failed: tar -xzf …` with tar's own stderr @@ -713,12 +860,17 @@ export function publishedArtefactDigest(name, version, artefact) { // the one worth naming: the published layout moved. const text = `${err.stdout ?? ''}${err.stderr ?? ''}` throw new Error( - `${spec}: could not extract \`${artefact.published}\` from the published tarball. ` + + `${spec}: could not extract \`${members.join('`, `')}\` from the published tarball. ` + 'The package layout has changed — update the `published` path in ' + `FROZEN_ARTEFACT_DIGESTS.\n${text.trim() || err.message}`, ) } + if (artefact.files) { + return fileDigests(artefact.files, (published) => + readFileSync(join(dir, published)), + ) + } return digestField( JSON.parse(readFileSync(join(dir, artefact.published), 'utf8')), artefact, @@ -811,9 +963,10 @@ export function reportBlockers(blockers) { " repository's to do.\n" : target ? ' 1. Publish the frozen package. For @cipherstash/eql that is the Phase 5\n' + - ' cutover in docs/plans/2026-08-13-eql-monorepo-absorption.md: repoint\n' + - ' npm trusted publishing to cipherstash/stack and release the version\n' + - ` above — ${target}.\n` + + ' cutover in docs/plans/2026-08-13-eql-monorepo-absorption.md; for the\n' + + ' @cipherstash/auth packages it is the arming PR of the stack-* crates\n' + + ' import. Either way: repoint npm trusted publishing to cipherstash/stack\n' + + ` and release the version above — ${target}.\n` + ' Every finding then clears on its own, with no further change here.\n' : ' 1. Publish the frozen package. Nothing above is frozen, so this way out\n' + ' is not available: the findings are manifests to fix, not a release to\n' + @@ -839,8 +992,9 @@ export function reportBlockers(blockers) { /** * The maps are parameters so the process tests can drive the blocking path - * while the real maps are empty. Deliberately not reachable from the command - * line: no flag or environment variable changes what a real run freezes. + * with a fixture, whatever the real maps hold. Deliberately not reachable + * from the command line: no flag or environment variable changes what a real + * run freezes. */ export function main({ frozen = FROZEN_PUBLISHERS, diff --git a/turbo.json b/turbo.json index ce949b44a..da8c14fdb 100644 --- a/turbo.json +++ b/turbo.json @@ -56,6 +56,15 @@ "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/skills/**", ".env*"], "env": ["STASH_POSTHOG_KEY"] }, + // The @cipherstash/auth napi binding. Its `build:native` is + // `napi build --release`, which needs Rust, so it is not the package's + // `build`: the root `build` and `test` never invoke cargo (the same split + // protect-ffi uses). `test` runs vitest and Biome against a binding built + // beforehand; `test:cargo` runs the crate's own tests. `wasm/**` is the + // output of `build:wasm`, cached with the native build it ships beside. + "@cipherstash/auth#build:native": { + "outputs": ["*.node", "native.d.ts", "wasm/**"] + }, // Typechecks `languages/typescript/packages/stack/dist-types` against the BUILT declarations, so // it must run after `build` — unlike `test:types`, which reads source. "test:types:dist": {