diff --git a/.github/workflows/tests-crates.yml b/.github/workflows/tests-crates.yml index 40cc572cc..b0f9a60f5 100644 --- a/.github/workflows/tests-crates.yml +++ b/.github/workflows/tests-crates.yml @@ -153,8 +153,36 @@ jobs: # `--allow-dirty` tolerates files earlier steps leave in the tree. Once a # version is on crates.io, cargo warns rather than fails on a dry run of # it. + # + # A crate with a git dependency outside its dev-dependencies cannot be + # published: cargo refuses to package it, and with a `version` beside the + # `git` it would build against crates.io's crate of that name, not the + # pinned rev. stack-kms pins vitaminc-kms to a git rev until vitaminc + # releases its key-provider traits (cipherstash/vitaminc#352). Such a + # crate is skipped here with a WARNING, as `test-eql.yml` does for a + # stack-encrypt not yet on crates.io: the PR cannot ship it, and + # release-crates fails loudly if a version bump tries. Every other crate + # in the list is still verified. - name: Verify the stack-* crates package cleanly for crates.io - run: cargo publish --locked --dry-run --allow-dirty -p stack-profile -p stack-auth -p stack-kms -p stack-encrypt-derive -p stack-encrypt + run: | + crates=(stack-profile stack-auth stack-kms stack-encrypt-derive stack-encrypt) + git_pinned=$(cargo metadata --locked --no-deps --format-version 1 | jq -r ' + .packages[] + | select(any(.dependencies[]; .kind != "dev" and ((.source // "") | startswith("git+")))) + | .name') + args=() + for crate in "${crates[@]}"; do + if grep -qx "$crate" <<<"$git_pinned"; then + echo "::warning title=${crate} cannot be published::${crate} has a git dependency, so cargo cannot package it. The dry run skips it until that dependency is on crates.io." + else + args+=(-p "$crate") + fi + done + if [ "${#args[@]}" -eq 0 ]; then + echo "Every crate in the list has a git dependency; nothing to verify." + exit 0 + fi + cargo publish --locked --dry-run --allow-dirty "${args[@]}" node-bindings: name: node bindings, napi typings, stack-auth-wasm diff --git a/Cargo.lock b/Cargo.lock index 6d6dce0f8..2d8bc0804 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2328,6 +2328,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.17+spec-1.1.0", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -3220,7 +3229,7 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "web-time", "zeroize", "zerokms-protocol", @@ -3242,7 +3251,7 @@ dependencies = [ "tempfile", "tokio", "url", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "zeroize", ] @@ -3286,7 +3295,7 @@ dependencies = [ "vitaminc-encrypt", "vitaminc-hmac", "vitaminc-prf", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "zeroize", "zerokms-protocol", ] @@ -3312,7 +3321,7 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "vitaminc-aead-value", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "zeroize", ] @@ -3345,7 +3354,9 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected", + "vitaminc-kms", + "vitaminc-protected 0.5.1", + "vitaminc-protected 0.8.0", "zeroize", "zerokms-protocol", ] @@ -3680,7 +3691,7 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit", + "toml_edit 0.22.27", ] [[package]] @@ -3730,6 +3741,18 @@ dependencies = [ "winnow 0.7.14", ] +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + [[package]] name = "toml_parser" version = "1.1.5+spec-1.1.0" @@ -4076,9 +4099,9 @@ dependencies = [ "vitaminc-aead", "vitaminc-context", "vitaminc-encrypt", - "vitaminc-protected", - "vitaminc-random", - "vitaminc-traits", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "vitaminc-traits 0.5.1", ] [[package]] @@ -4091,8 +4114,8 @@ dependencies = [ "serde", "vitaminc-aead-derive", "vitaminc-context", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", "zeroize", ] @@ -4114,10 +4137,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" dependencies = [ "vitaminc-aead", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "zeroize", ] +[[package]] +name = "vitaminc-async-traits" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "vitaminc-protected 0.8.0", + "vitaminc-traits 0.8.0", +] + [[package]] name = "vitaminc-context" version = "0.5.1" @@ -4125,7 +4157,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", - "vitaminc-protected", + "vitaminc-protected 0.5.1", ] [[package]] @@ -4137,8 +4169,8 @@ dependencies = [ "aes-gcm", "aws-lc-rs", "vitaminc-aead", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", "zeroize", ] @@ -4151,7 +4183,20 @@ dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected", + "vitaminc-protected 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-kms" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "futures", + "thiserror 2.0.18", + "vitaminc-async-traits", + "vitaminc-protected 0.8.0", + "vitaminc-traits 0.8.0", "zeroize", ] @@ -4164,7 +4209,7 @@ dependencies = [ "mutants", "thiserror 2.0.18", "vitaminc-context", - "vitaminc-protected", + "vitaminc-protected 0.5.1", ] [[package]] @@ -4180,7 +4225,23 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive", + "vitaminc-protected-derive 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.18", + "vitaminc-protected-derive 0.8.0", "zeroize", ] @@ -4195,6 +4256,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "vitaminc-protected-derive" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "vitaminc-random" version = "0.5.1" @@ -4205,8 +4277,22 @@ dependencies = [ "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random-derives", + "vitaminc-protected 0.5.1", + "vitaminc-random-derives 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-random" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.18", + "vitaminc-protected 0.8.0", + "vitaminc-random-derives 0.8.0", "zeroize", ] @@ -4221,6 +4307,16 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "vitaminc-random-derives" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "vitaminc-traits" version = "0.5.1" @@ -4232,8 +4328,23 @@ dependencies = [ "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-traits" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "vitaminc-protected 0.8.0", + "vitaminc-random 0.8.0", "zeroize", ] @@ -4870,6 +4981,9 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] name = "winreg" diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index 88e3723f7..bace4e6fe 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -1486,6 +1486,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2056,7 +2065,7 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "web-time", "zeroize", "zerokms-protocol", @@ -2080,7 +2089,7 @@ dependencies = [ "vitaminc-encrypt", "vitaminc-hmac", "vitaminc-prf", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "zeroize", ] @@ -2110,7 +2119,7 @@ dependencies = [ "stack-kms", "uuid", "vitaminc-aead-value", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "zeroize", "zerokms-protocol", ] @@ -2123,7 +2132,7 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "vitaminc-aead-value", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "zeroize", ] @@ -2150,7 +2159,9 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected", + "vitaminc-kms", + "vitaminc-protected 0.5.1", + "vitaminc-protected 0.8.0", "zeroize", "zerokms-protocol", ] @@ -2357,6 +2368,36 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2576,9 +2617,9 @@ dependencies = [ "vitaminc-aead", "vitaminc-context", "vitaminc-encrypt", - "vitaminc-protected", - "vitaminc-random", - "vitaminc-traits", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "vitaminc-traits 0.5.1", ] [[package]] @@ -2591,8 +2632,8 @@ dependencies = [ "serde", "vitaminc-aead-derive", "vitaminc-context", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", "zeroize", ] @@ -2614,10 +2655,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" dependencies = [ "vitaminc-aead", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "zeroize", ] +[[package]] +name = "vitaminc-async-traits" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "vitaminc-protected 0.8.0", + "vitaminc-traits 0.8.0", +] + [[package]] name = "vitaminc-context" version = "0.5.1" @@ -2625,7 +2675,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", - "vitaminc-protected", + "vitaminc-protected 0.5.1", ] [[package]] @@ -2637,8 +2687,8 @@ dependencies = [ "aes-gcm", "aws-lc-rs", "vitaminc-aead", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", "zeroize", ] @@ -2651,7 +2701,20 @@ dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected", + "vitaminc-protected 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-kms" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "futures", + "thiserror 2.0.20", + "vitaminc-async-traits", + "vitaminc-protected 0.8.0", + "vitaminc-traits 0.8.0", "zeroize", ] @@ -2664,7 +2727,7 @@ dependencies = [ "mutants", "thiserror 2.0.20", "vitaminc-context", - "vitaminc-protected", + "vitaminc-protected 0.5.1", ] [[package]] @@ -2680,7 +2743,23 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive", + "vitaminc-protected-derive 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.20", + "vitaminc-protected-derive 0.8.0", "zeroize", ] @@ -2695,6 +2774,17 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "vitaminc-protected-derive" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "vitaminc-random" version = "0.5.1" @@ -2705,8 +2795,22 @@ dependencies = [ "getrandom 0.4.3", "rand 0.10.2", "thiserror 2.0.20", - "vitaminc-protected", - "vitaminc-random-derives", + "vitaminc-protected 0.5.1", + "vitaminc-random-derives 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-random" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand 0.10.2", + "thiserror 2.0.20", + "vitaminc-protected 0.8.0", + "vitaminc-random-derives 0.8.0", "zeroize", ] @@ -2721,6 +2825,16 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "vitaminc-random-derives" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "vitaminc-traits" version = "0.5.1" @@ -2732,8 +2846,23 @@ dependencies = [ "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-traits" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.20", + "vitaminc-protected 0.8.0", + "vitaminc-random 0.8.0", "zeroize", ] @@ -2979,6 +3108,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/languages/golang/encrypt/guest/src/status.rs b/languages/golang/encrypt/guest/src/status.rs index 8425272e4..a42f6de9c 100644 --- a/languages/golang/encrypt/guest/src/status.rs +++ b/languages/golang/encrypt/guest/src/status.rs @@ -216,6 +216,14 @@ fn status_for_kms(error: &stack_kms::Error) -> u32 { stack_kms::Error::ConnectionInit(_) | stack_kms::Error::InvalidEndpoint(_) => { STATUS_KMS_TRANSPORT } + // Neither reaches ZeroKMS: the provider refuses its own input first. + // A binding that is not UTF-8 cannot be a descriptor, and a key id + // too short to hold an IV cannot name a key — so both are malformed + // input at the boundary, which is what a host can act on, rather + // than a ZeroKMS verdict it can only log. + stack_kms::Error::BindingNotUtf8 | stack_kms::Error::MalformedKeyId { .. } => { + STATUS_ENCODING + } stack_kms::Error::Unexpected(_) => STATUS_KMS_OTHER, } } @@ -367,6 +375,24 @@ mod tests { ); } + /// The provider's own input refusals never reach ZeroKMS, so they are + /// the caller's to fix rather than a verdict to log. Pinned because the + /// enclosing match is exhaustive over `stack_kms::Error`: a variant + /// added upstream fails the build, but one *reclassified* here would + /// not, and the difference between "fix your input" and "log this" is + /// the whole point of the status word. + #[test] + fn the_providers_own_input_refusals_are_encoding() { + assert_eq!( + status_for_kms(&stack_kms::Error::BindingNotUtf8), + STATUS_ENCODING + ); + assert_eq!( + status_for_kms(&stack_kms::Error::MalformedKeyId { len: 4, min: 16 }), + STATUS_ENCODING + ); + } + #[test] fn a_plan_refusal_is_the_callers_input() { assert_eq!( diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index 3eaca509f..b93d5bd37 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -1506,6 +1506,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -1971,7 +1980,7 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "web-time", "zeroize", "zerokms-protocol", @@ -1995,7 +2004,7 @@ dependencies = [ "vitaminc-encrypt", "vitaminc-hmac", "vitaminc-prf", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "zeroize", ] @@ -2016,7 +2025,7 @@ dependencies = [ "libfuzzer-sys", "stack-encrypt", "uuid", - "vitaminc-protected", + "vitaminc-protected 0.5.1", ] [[package]] @@ -2042,7 +2051,9 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected", + "vitaminc-kms", + "vitaminc-protected 0.5.1", + "vitaminc-protected 0.8.0", "zeroize", "zerokms-protocol", ] @@ -2241,6 +2252,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2438,9 +2479,9 @@ dependencies = [ "vitaminc-aead", "vitaminc-context", "vitaminc-encrypt", - "vitaminc-protected", - "vitaminc-random", - "vitaminc-traits", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "vitaminc-traits 0.5.1", ] [[package]] @@ -2453,8 +2494,8 @@ dependencies = [ "serde", "vitaminc-aead-derive", "vitaminc-context", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", "zeroize", ] @@ -2476,10 +2517,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" dependencies = [ "vitaminc-aead", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "zeroize", ] +[[package]] +name = "vitaminc-async-traits" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "vitaminc-protected 0.8.0", + "vitaminc-traits 0.8.0", +] + [[package]] name = "vitaminc-context" version = "0.5.1" @@ -2487,7 +2537,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", - "vitaminc-protected", + "vitaminc-protected 0.5.1", ] [[package]] @@ -2499,8 +2549,8 @@ dependencies = [ "aes-gcm", "aws-lc-rs", "vitaminc-aead", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", "zeroize", ] @@ -2513,7 +2563,20 @@ dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected", + "vitaminc-protected 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-kms" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "futures", + "thiserror 2.0.18", + "vitaminc-async-traits", + "vitaminc-protected 0.8.0", + "vitaminc-traits 0.8.0", "zeroize", ] @@ -2526,7 +2589,7 @@ dependencies = [ "mutants", "thiserror 2.0.18", "vitaminc-context", - "vitaminc-protected", + "vitaminc-protected 0.5.1", ] [[package]] @@ -2542,7 +2605,23 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive", + "vitaminc-protected-derive 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.18", + "vitaminc-protected-derive 0.8.0", "zeroize", ] @@ -2557,6 +2636,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "vitaminc-protected-derive" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "vitaminc-random" version = "0.5.1" @@ -2567,8 +2657,22 @@ dependencies = [ "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random-derives", + "vitaminc-protected 0.5.1", + "vitaminc-random-derives 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-random" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.18", + "vitaminc-protected 0.8.0", + "vitaminc-random-derives 0.8.0", "zeroize", ] @@ -2583,6 +2687,16 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "vitaminc-random-derives" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "vitaminc-traits" version = "0.5.1" @@ -2594,8 +2708,23 @@ dependencies = [ "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-traits" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "vitaminc-protected 0.8.0", + "vitaminc-random 0.8.0", "zeroize", ] @@ -2949,6 +3078,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/packages/stack-kms/CHANGELOG.md b/packages/stack-kms/CHANGELOG.md new file mode 100644 index 000000000..0c967fddc --- /dev/null +++ b/packages/stack-kms/CHANGELOG.md @@ -0,0 +1,32 @@ +# 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] + +### Added + +- `ZeroKmsKeyset`: one ZeroKMS keyset, resolved once over a shared + `Arc`, as a `vitaminc-kms` `KeyProvider<32>` and + `IndexKeyProvider<32>`. A client library can be generic over those traits + and run on ZeroKMS or on another data key source. It reconstructs keys + client-and-server, mints one key per value, and binds each key to its + descriptor. Its `KeyId` is the IV followed by the tag, and its custody is + `BackendHeld`: the id names a key ZeroKMS rebuilds, not a wrapped copy of + the key. +- `Error::BindingNotUtf8` (code `stack_kms::binding_not_utf8`) and + `Error::MalformedKeyId` (code `stack_kms::malformed_key_id`), for a binding + or a key id that `ZeroKmsKeyset` cannot use. + +### Changed + +- `vitaminc-kms` is a git dependency until vitaminc releases the key + provider traits (cipherstash/vitaminc#352). Until then this crate cannot + be published. + +## [0.1.0] - 2026-10-04 + +The first crates.io release. diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml index 105a38230..1f9368236 100644 --- a/packages/stack-kms/Cargo.toml +++ b/packages/stack-kms/Cargo.toml @@ -47,9 +47,25 @@ stack-auth = { workspace = true } zerokms-protocol = { workspace = true } vitaminc = { workspace = true, features = ["protected", "random"] } +# The key-provider traits `ZeroKmsKeyset` implements (`KeyProvider`, +# `IndexKeyProvider`, `Binding`) and their value types. `default-features = +# false` keeps every vendor SDK — and the native HTTP/TLS stack the WASI gate +# forbids — out of this crate's graph: nothing here talks to AWS, Azure, +# Google Cloud or Vault, only to ZeroKMS. +# +# Pinned to a git rev because these traits are not published yet. Swap to a +# version once vitaminc releases them. +vitaminc-kms = { git = "https://github.com/cipherstash/vitaminc", rev = "5b8a9c7", default-features = false } # Direct dep required because the `TimingSafeEq` derive macro expands to a # `::vitaminc_protected` path. vitaminc-protected = { workspace = true } +# `vitaminc-kms` is built against its own git-sourced `vitaminc-protected` +# (0.8.0 at this rev), not the crates.io 0.5.1 the workspace pins above. A +# differently-sourced dependency is a *different* type at compile time, so +# this must resolve to the identical git rev as `vitaminc-kms`'s own copy or +# every call into its types is an E0308. Renamed so it cannot collide with +# the entry above; used only by `provider.rs`. +vitaminc-protected-kms = { package = "vitaminc-protected", git = "https://github.com/cipherstash/vitaminc", rev = "5b8a9c7" } blake3 = { workspace = true } lazy_static = { workspace = true, optional = true } diff --git a/packages/stack-kms/fuzz/Cargo.lock b/packages/stack-kms/fuzz/Cargo.lock index a18c2eba9..a2434a217 100644 --- a/packages/stack-kms/fuzz/Cargo.lock +++ b/packages/stack-kms/fuzz/Cargo.lock @@ -1474,6 +1474,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -1956,7 +1965,7 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected", + "vitaminc-protected 0.5.1", "web-time", "zeroize", "zerokms-protocol", @@ -1984,7 +1993,9 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected", + "vitaminc-kms", + "vitaminc-protected 0.5.1", + "vitaminc-protected 0.8.0", "zeroize", "zerokms-protocol", ] @@ -2177,6 +2188,36 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2365,9 +2406,9 @@ dependencies = [ "vitaminc-aead", "vitaminc-context", "vitaminc-encrypt", - "vitaminc-protected", - "vitaminc-random", - "vitaminc-traits", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "vitaminc-traits 0.5.1", ] [[package]] @@ -2380,8 +2421,8 @@ dependencies = [ "serde", "vitaminc-aead-derive", "vitaminc-context", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", "zeroize", ] @@ -2396,6 +2437,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "vitaminc-async-traits" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "vitaminc-protected 0.8.0", + "vitaminc-traits 0.8.0", +] + [[package]] name = "vitaminc-context" version = "0.5.1" @@ -2403,7 +2453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", - "vitaminc-protected", + "vitaminc-protected 0.5.1", ] [[package]] @@ -2415,8 +2465,21 @@ dependencies = [ "aes-gcm", "aws-lc-rs", "vitaminc-aead", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-kms" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "futures", + "thiserror 2.0.18", + "vitaminc-async-traits", + "vitaminc-protected 0.8.0", + "vitaminc-traits 0.8.0", "zeroize", ] @@ -2433,7 +2496,23 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.18", - "vitaminc-protected-derive", + "vitaminc-protected-derive 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.18", + "vitaminc-protected-derive 0.8.0", "zeroize", ] @@ -2448,6 +2527,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "vitaminc-protected-derive" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "vitaminc-random" version = "0.5.1" @@ -2458,8 +2548,22 @@ dependencies = [ "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random-derives", + "vitaminc-protected 0.5.1", + "vitaminc-random-derives 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-random" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.18", + "vitaminc-protected 0.8.0", + "vitaminc-random-derives 0.8.0", "zeroize", ] @@ -2474,6 +2578,16 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "vitaminc-random-derives" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "vitaminc-traits" version = "0.5.1" @@ -2485,8 +2599,23 @@ dependencies = [ "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "zeroize", +] + +[[package]] +name = "vitaminc-traits" +version = "0.8.0" +source = "git+https://github.com/cipherstash/vitaminc?rev=5b8a9c7#5b8a9c7721975d7128dda576b6f4d63e10dd6ffb" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "vitaminc-protected 0.8.0", + "vitaminc-random 0.8.0", "zeroize", ] @@ -2840,6 +2969,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index 6b8d0bc6d..edddcf212 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -565,57 +565,18 @@ where } #[cfg(test)] -mod test_connection; +pub(crate) 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 { - 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) -> 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 { diff --git a/packages/stack-kms/src/client/test_connection.rs b/packages/stack-kms/src/client/test_connection.rs index 17560b471..84d634409 100644 --- a/packages/stack-kms/src/client/test_connection.rs +++ b/packages/stack-kms/src/client/test_connection.rs @@ -1,10 +1,61 @@ //! In-memory [`ZeroKMSConnection`] used by unit tests to stub ZeroKMS -//! responses without touching the network. +//! responses without touching the network, plus the fixtures every unit +//! test built on it needs (a client key, a client over the stub, and the +//! key material ZeroKMS would return). +//! +//! Shared by `client.rs`'s own tests and by `provider.rs`, which drives the +//! key-provider implementation over the same stub. use async_mutex::Mutex; -use zerokms_protocol::{ViturRequest, ViturRequestError}; +use uuid::uuid; +use zerokms_protocol::{GeneratedKey, ViturKeyMaterial, ViturRequest, ViturRequestError}; +use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + +use crate::client::{Client, ClientOpts}; use crate::connection::{ZeroKMSConnection, ZeroKMSConnectionInit}; +use crate::key::{ClientKey, V1KeySet}; + +/// A fresh, random [`ClientKey`]. Data and index keys are derived from it, +/// so a test that compares derived material must reuse one value (reach it +/// through `StackKms::client_key`) rather than call this twice. +pub(crate) 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), + } +} + +/// A low-level [`Client`] over a stub connection the callback configures. +pub(crate) fn build_client( + callback: impl FnOnce(TestConnectionBuilder) -> TestConnectionBuilder, +) -> Client { + 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. +pub(crate) fn key_material() -> ViturKeyMaterial { + ViturKeyMaterial::from(vec![7u8; 528]) +} + +pub(crate) fn generated_key(tag: Vec) -> GeneratedKey { + GeneratedKey { + key_material: key_material(), + tag, + decryption_policy: None, + } +} type EffectHandlers = Vec<(String, Box)>; type RequestHandlers = Vec<(String, Result)>; diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index 9287e6f18..4126827af 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -339,6 +339,8 @@ mod codes { Error::ConnectionInit(Box::new(std::io::Error::other("no"))) }, Error::InvalidEndpoint(_) => Error::InvalidEndpoint(InvalidEndpoint::Userinfo), + Error::BindingNotUtf8 => Error::BindingNotUtf8, + Error::MalformedKeyId { .. } => Error::MalformedKeyId { len: 3, min: 16 }, Error::Unexpected(_) => Error::Unexpected("unexpected".into()), ])); errors.extend(boxed(variants![ @@ -748,6 +750,20 @@ pub enum Error { crate::endpoint::InvalidEndpoint, ), + /// A [`Binding`](vitaminc_kms::provider::Binding) handed to + /// [`ZeroKmsKeyset`](crate::ZeroKmsKeyset) was not UTF-8. ZeroKMS + /// descriptors are strings. + #[error("a ZeroKMS binding must be UTF-8 text (it is the descriptor)")] + #[diagnostic(code(stack_kms::binding_not_utf8))] + BindingNotUtf8, + + /// A [`KeyId`](vitaminc_kms::KeyId) handed to + /// [`ZeroKmsKeyset`](crate::ZeroKmsKeyset) is too short to hold the IV + /// that leads every id it mints. + #[error("ZeroKMS key id is {len} bytes; the IV alone is {min}")] + #[diagnostic(code(stack_kms::malformed_key_id))] + MalformedKeyId { len: usize, min: usize }, + #[error("Unexpected error: {0}")] #[diagnostic(code(stack_kms::unexpected))] Unexpected(String), @@ -760,9 +776,11 @@ impl ErrorPayload for Error { Self::RetrieveKey(error) => error.payload(), Self::LoadKeyset(error) => error.payload(), Self::Auth(error) => error.payload(), - Self::ConnectionInit(_) | Self::InvalidEndpoint(_) | Self::Unexpected(_) => { - serde_json::Map::new() - } + Self::ConnectionInit(_) + | Self::InvalidEndpoint(_) + | Self::BindingNotUtf8 + | Self::MalformedKeyId { .. } + | Self::Unexpected(_) => serde_json::Map::new(), } } } diff --git a/packages/stack-kms/src/key_provider.rs b/packages/stack-kms/src/key_provider.rs index 2e87ba760..c87b96b1a 100644 --- a/packages/stack-kms/src/key_provider.rs +++ b/packages/stack-kms/src/key_provider.rs @@ -4,6 +4,15 @@ //! to generate or retrieve data keys. Each provider yields a complete [`ClientKey`] //! (client ID + key material) from a self-contained source. //! +//!
+//! +//! This is the *client key* provider — the ZeroKMS credential half. It is not +//! the **key provider** a client library such as `stack-encrypt` is generic +//! over: that is [`vitaminc_kms::provider::KeyProvider`], which this crate +//! implements as [`ZeroKmsKeyset`](crate::ZeroKmsKeyset). +//! +//!
+//! //! # Built-in providers //! //! | Provider | Source | diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index ee484f39e..8ffd7863a 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -15,6 +15,18 @@ //! Encryption/decryption, keyset and client management, and config save/load //! all remain in `cipherstash-client` for now. //! +//! # ZeroKMS as a key provider +//! +//! A client library does not have to take a `StackKms` directly. It can be +//! generic over the `vitaminc-kms` **key provider** traits +//! ([`vitaminc_kms::provider::KeyProvider`] and +//! [`vitaminc_kms::provider::IndexKeyProvider`]), so the same code runs on +//! ZeroKMS or on any vitaminc **data key source** (AWS KMS, Azure Key Vault, +//! Google Cloud KMS, Vault Transit). [`ZeroKmsKeyset`] is this crate's +//! implementation of those traits: one resolved keyset over a shared +//! `Arc`, binding each value's descriptor into the data key +//! server-side. +//! //! # Quick start //! // The quick start goes through `StackKmsBuilder`, which configures the default @@ -88,6 +100,7 @@ mod key_provider; mod key_source; mod maybe_send; mod payload; +pub mod provider; mod secret_key; #[cfg(feature = "http")] mod user_agent; @@ -138,7 +151,14 @@ pub use key_source::DeterministicSource; pub use key_source::FakeDataKeySource; pub use key_source::{DataKeySource, IndexKeySource}; -// Key providers +// ZeroKMS as a `vitaminc-kms` key provider: the trait shape a client library +// can be generic over. `StackKms` itself stays a plain ZeroKMS client. +pub use provider::ZeroKmsKeyset; + +// Credential providers: the ZeroKMS *client key*. Note the name collision — +// `key_provider::KeyProvider` sources that credential, and is not the key +// provider of the glossary (`vitaminc_kms::provider::KeyProvider`, which +// `ZeroKmsKeyset` above implements). pub use key_provider::{ EnvKeyProvider, FallbackKeyProvider, KeyProvider, KeyProviderError, StaticKeyProvider, }; diff --git a/packages/stack-kms/src/provider.rs b/packages/stack-kms/src/provider.rs new file mode 100644 index 000000000..74b8f987d --- /dev/null +++ b/packages/stack-kms/src/provider.rs @@ -0,0 +1,607 @@ +//! ZeroKMS as a key provider: [`ZeroKmsKeyset`] implements +//! [`vitaminc_kms::KeyProvider`] and [`vitaminc_kms::IndexKeyProvider`] +//! over one resolved keyset, so a `StackCipher` generic over those traits +//! runs on ZeroKMS exactly as it runs on a vendor data key source. +//! +//! What ZeroKMS is, at the trait level: +//! +//! - `RECONSTRUCTION = ClientAndServer`: the server returns partial key +//! material and the data key only exists after the client's own keyset +//! re-enciphers it (`recipher`). A server-side compromise plus a stored +//! key id does not recover a key. +//! - `CUSTODY = BackendHeld`: the [`KeyId`] is not the data key in wrapped +//! form. It names a key the service rebuilds from material it holds, so +//! losing a stored id does not lose the only copy of the key. +//! +//! Every value gets its own key, in one round trip per batch. The binding is +//! the descriptor: ZeroKMS binds it into the key server-side, refuses +//! retrieval under a different one, and logs it per retrieval (ADR 0002 in +//! `packages/stack-encrypt/docs/adr`). +//! +//! The [`KeyId`] is the ZeroKMS IV followed by the tag: the two halves the +//! service needs back to re-derive a key. + +use std::borrow::Cow; +use std::sync::Arc; + +use uuid::Uuid; +use vitaminc_kms::provider::{Binding, IndexKeyProvider, KeyProvider}; +use vitaminc_kms::{GeneratedDataKey, IndexKeyMaterial, KeyCustody, KeyId, KeyReconstruction}; +use vitaminc_protected_kms::Protected; +use zerokms_protocol::IdentifiedBy; + +use crate::client::StackKms; +use crate::connection::ZeroKMSConnection; +use crate::errors::Error; +use crate::key::IndexKey; +use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; +use recipher::key::Iv; +use stack_auth::{AuthStrategy, AuthStrategyBounds}; + +/// Length of the ZeroKMS IV that leads every [`KeyId`] this provider mints. +const IV_LEN: usize = 16; + +/// One ZeroKMS keyset, resolved once, as a key provider. +/// +/// Holds an `Arc` so several keysets can share one client, its +/// credentials and its connection. Build it with [`ZeroKmsKeyset::new`]. +pub struct ZeroKmsKeyset { + kms: Arc>, + keyset_id: Uuid, + index_key: IndexKey, +} + +impl ZeroKmsKeyset +where + C: AuthStrategyBounds, + for<'a> &'a C: AuthStrategy, + Conn: ZeroKMSConnection + Send + Sync, +{ + /// Resolve `keyset` (by id or name; `None` is the client's default + /// keyset) with one `load_keyset` call, which also yields the index key. + pub async fn new( + kms: Arc>, + keyset: Option, + ) -> Result { + let (keyset, index_key) = kms.load_keyset(keyset).await?; + Ok(Self { + kms, + keyset_id: keyset.id, + index_key, + }) + } + + /// The resolved keyset id. + pub fn keyset_id(&self) -> Uuid { + self.keyset_id + } + + /// The shared client. Several keysets can hold the same one — the + /// sharing is this type's own business, so the borrow hands out the + /// client, not the `Arc` around it. + pub fn kms(&self) -> &StackKms { + &self.kms + } + + /// ZeroKMS descriptors are strings; a binding that is not UTF-8 cannot + /// be one. + fn descriptor(binding: Binding<'_>) -> Result<&str, Error> { + std::str::from_utf8(binding.as_bytes()).map_err(|_| Error::BindingNotUtf8) + } + + /// Split a [`KeyId`] minted by this provider back into its IV and tag. + fn split_key_id(key_id: &KeyId) -> Result<(Iv, &[u8]), Error> { + let bytes = key_id.as_bytes(); + if bytes.len() < IV_LEN { + return Err(Error::MalformedKeyId { + len: bytes.len(), + min: IV_LEN, + }); + } + let (iv_bytes, tag) = bytes.split_at(IV_LEN); + let mut iv = Iv::default(); + iv.copy_from_slice(iv_bytes); + Ok((iv, tag)) + } +} + +impl KeyProvider<32> for ZeroKmsKeyset +where + C: AuthStrategyBounds, + for<'a> &'a C: AuthStrategy, + Conn: ZeroKMSConnection + Send + Sync, +{ + type Error = Error; + + const RECONSTRUCTION: KeyReconstruction = KeyReconstruction::ClientAndServer; + const CUSTODY: KeyCustody = KeyCustody::BackendHeld; + + async fn generate_keys( + &self, + bindings: &[Binding<'_>], + ) -> Result>, Error> { + let payloads = bindings + .iter() + .map(|binding| { + Ok(GenerateKeyPayload::new( + Self::descriptor(*binding)?, + Cow::Owned(Vec::new()), + )) + }) + .collect::, Error>>()?; + + let keys = self + .kms + .generate_keys(payloads, Some(self.keyset_id), None) + .await?; + + Ok(keys + .into_iter() + .map(|key| { + let mut key_id = Vec::with_capacity(IV_LEN + key.tag.len()); + key_id.extend_from_slice(&key.key.iv); + key_id.extend_from_slice(&key.tag); + GeneratedDataKey { + plaintext: Protected::new(key.key.key), + key_id: KeyId::new(key_id), + } + }) + .collect()) + } + + async fn retrieve_keys( + &self, + keys: &[(KeyId, Binding<'_>)], + ) -> Result>, Error> { + let payloads = keys + .iter() + .map(|(key_id, binding)| { + let (iv, tag) = Self::split_key_id(key_id)?; + Ok(RetrieveKeyPayload::new( + iv, + Self::descriptor(*binding)?, + tag, + )) + }) + .collect::, Error>>()?; + + let keys = self + .kms + .retrieve_keys(payloads, Some(self.keyset_id), None) + .await?; + + Ok(keys + .into_iter() + .map(|key| Protected::new(key.key)) + .collect()) + } +} + +impl IndexKeyProvider<32> for ZeroKmsKeyset +where + C: AuthStrategyBounds, + for<'a> &'a C: AuthStrategy, + Conn: ZeroKMSConnection + Send + Sync, +{ + type Error = Error; + + async fn load_index_key(&self) -> Result, Error> { + Ok(IndexKeyMaterial(Protected::new(*self.index_key.key()))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::test_connection::{ + generated_key, key_material, random_client_key, TestConnection, TestConnectionBuilder, + }; + use crate::client::ClientOpts; + use crate::key::DataKey; + use stack_auth::StaticTokenStrategy; + use std::sync::Mutex; + use uuid::uuid; + use vitaminc_protected_kms::Controlled; + use zerokms_protocol::{ + GenerateKeyRequest, GenerateKeyResponse, Keyset, LoadKeysetRequest, LoadKeysetResponse, + RetrieveKeyRequest, RetrieveKeyResponse, RetrievedKey, + }; + + const KEYSET_ID: Uuid = uuid!("11111111-1111-1111-1111-111111111111"); + + type TestKeyset = ZeroKmsKeyset; + + /// The `load_keyset` response [`ZeroKmsKeyset::new`] consumes: the keyset + /// this provider resolves to, plus the partial key material its index key + /// is derived from. + fn load_keyset_response() -> LoadKeysetResponse { + LoadKeysetResponse { + partial_index_key: RetrievedKey { + key_material: key_material(), + }, + keyset: Keyset { + id: KEYSET_ID, + name: "default".to_string(), + description: String::new(), + is_disabled: false, + is_default: true, + }, + } + } + + /// A `StackKms` over a stub connection the callback configures. Every + /// request a test expects must be stubbed up front — the stub panics on + /// an unstubbed endpoint, which is what the "before any request" + /// assertions below rely on. + fn build_kms( + callback: impl FnOnce(TestConnectionBuilder) -> TestConnectionBuilder, + ) -> Arc> { + let opts = ClientOpts::new(callback(TestConnectionBuilder::new())); + Arc::new( + StackKms::<_, TestConnection>::connect( + opts, + StaticTokenStrategy::new("static-token"), + random_client_key(), + ) + .expect("connect over a test connection"), + ) + } + + /// A provider whose one `load_keyset` call is already stubbed, over a + /// connection the callback may stub further requests on. + async fn build_keyset( + callback: impl FnOnce(TestConnectionBuilder) -> TestConnectionBuilder, + ) -> TestKeyset { + let kms = build_kms(|builder| { + callback(builder.add_success_response::(load_keyset_response())) + }); + ZeroKmsKeyset::new(kms, None) + .await + .expect("the keyset resolves") + } + + /// Captures the one request sent to an endpoint, for assertions after the + /// call returns. + struct Captured(Arc>>); + + impl Captured { + fn new() -> Self { + Self(Arc::new(Mutex::new(None))) + } + + fn recorder(&self) -> impl FnOnce(R) + Send + 'static { + let slot = Arc::clone(&self.0); + move |request| *slot.lock().unwrap() = Some(request) + } + + fn take(&self) -> R { + self.0.lock().unwrap().take().expect("a request was sent") + } + } + + mod new { + use super::*; + + #[tokio::test] + async fn resolves_the_keyset_with_one_load_keyset_request() { + // One `load-keyset` handler is stubbed and the stub removes it + // once used, so a second request would panic. + let keyset = build_keyset(|builder| builder).await; + + assert_eq!(keyset.keyset_id(), KEYSET_ID); + } + + #[tokio::test] + async fn load_index_key_returns_the_keysets_derived_index_key() { + let keyset = build_keyset(|builder| builder).await; + + // The same derivation `load_keyset` performs — see + // `load_keyset_derives_a_deterministic_index_key` in `client.rs`. + let expected = + IndexKey::from_key_material(keyset.kms().client_key(), &key_material()).unwrap(); + + let material = keyset.load_index_key().await.unwrap(); + + assert_eq!(&material.0.risky_unwrap(), expected.key()); + } + + #[tokio::test] + async fn load_index_key_makes_no_further_request() { + let keyset = build_keyset(|builder| builder).await; + + // The stub has no handler left, so a request here would panic. + let first = keyset.load_index_key().await.unwrap(); + let second = keyset.load_index_key().await.unwrap(); + + assert_eq!(first.0.risky_unwrap(), second.0.risky_unwrap()); + } + + #[tokio::test] + async fn two_keysets_share_one_client() { + let kms = build_kms(|builder| { + builder + .add_success_response::(load_keyset_response()) + .add_success_response::(load_keyset_response()) + }); + + let by_default = ZeroKmsKeyset::new(Arc::clone(&kms), None).await.unwrap(); + let by_id = ZeroKmsKeyset::new(Arc::clone(&kms), Some(KEYSET_ID.into())) + .await + .unwrap(); + + assert_eq!(by_default.keyset_id(), KEYSET_ID); + assert_eq!(by_id.keyset_id(), KEYSET_ID); + assert!(std::ptr::eq(by_default.kms(), by_id.kms())); + // The two providers, plus the handle this test still holds. + assert_eq!(Arc::strong_count(&kms), 3); + } + } + + mod generate_keys { + use super::*; + + #[tokio::test] + async fn sends_one_spec_per_binding_carrying_the_descriptor_and_keyset_id() { + let captured = Captured::>::new(); + let keyset = build_keyset(|builder| { + builder + .add_effect::(captured.recorder()) + .add_success_response::(GenerateKeyResponse { + keys: vec![generated_key(vec![1]), generated_key(vec![2])], + }) + }) + .await; + + let keys = keyset + .generate_keys(&[Binding::from("users/email"), Binding::from("users/name")]) + .await + .unwrap(); + + let request = captured.take(); + assert_eq!(request.keys.len(), 2); + assert_eq!(request.keys[0].descriptor, "users/email"); + assert_eq!(request.keys[1].descriptor, "users/name"); + assert_eq!(request.keyset_id, Some(IdentifiedBy::Uuid(KEYSET_ID))); + assert_eq!(keys.len(), 2); + } + + #[tokio::test] + async fn key_ids_are_the_iv_followed_by_the_servers_tag() { + let captured = Captured::>::new(); + let keyset = build_keyset(|builder| { + builder + .add_effect::(captured.recorder()) + .add_success_response::(GenerateKeyResponse { + keys: vec![generated_key(vec![1, 2, 3]), generated_key(vec![4, 5])], + }) + }) + .await; + + let keys = keyset + .generate_keys(&[Binding::from("a"), Binding::from("b")]) + .await + .unwrap(); + + let request = captured.take(); + let tags: [&[u8]; 2] = [&[1, 2, 3], &[4, 5]]; + for ((key, spec), tag) in keys.iter().zip(request.keys.iter()).zip(tags) { + let bytes = key.key_id.as_bytes(); + assert_eq!(bytes.len(), IV_LEN + tag.len()); + assert_eq!(&bytes[..IV_LEN], spec.iv.as_ref()); + assert_eq!(&bytes[IV_LEN..], tag); + } + } + + #[tokio::test] + async fn plaintext_is_what_data_key_derives_from_the_same_material() { + let captured = Captured::>::new(); + let keyset = build_keyset(|builder| { + builder + .add_effect::(captured.recorder()) + .add_success_response::(GenerateKeyResponse { + keys: vec![generated_key(vec![7])], + }) + }) + .await; + + let keys = keyset.generate_keys(&[Binding::from("a")]).await.unwrap(); + + let iv = captured.take().keys[0].iv.into_inner(); + let expected = + DataKey::from_key_material(keyset.kms().client_key(), iv, &key_material()).unwrap(); + + assert_eq!(&keys[0].plaintext.clone().risky_unwrap(), expected.key()); + } + + #[tokio::test] + async fn a_non_utf8_binding_fails_before_any_request() { + // No `generate-data-key` handler is stubbed, so reaching the + // connection would panic rather than return this error. + let keyset = build_keyset(|builder| builder).await; + + // `let ... else` rather than `expect_err`: the success type + // deliberately has no `Debug` (it holds key material). + let Err(err) = keyset + .generate_keys(&[Binding::from("ok"), Binding::new(&[0xff, 0xfe])]) + .await + else { + panic!("a non-UTF-8 binding cannot be a descriptor"); + }; + + assert!(matches!(err, Error::BindingNotUtf8), "got: {err:?}"); + } + } + + mod retrieve_keys { + use super::*; + + fn key_id(iv: [u8; IV_LEN], tag: &[u8]) -> KeyId { + let mut bytes = iv.to_vec(); + bytes.extend_from_slice(tag); + KeyId::new(bytes) + } + + #[tokio::test] + async fn sends_the_split_iv_tag_and_binding_in_order() { + let captured = Captured::>::new(); + let keyset = build_keyset(|builder| { + builder + .add_effect::(captured.recorder()) + .add_success_response::(RetrieveKeyResponse { + keys: vec![ + RetrievedKey { + key_material: key_material(), + }, + RetrievedKey { + key_material: key_material(), + }, + ], + }) + }) + .await; + + let keys = keyset + .retrieve_keys(&[ + (key_id([1u8; IV_LEN], &[9, 9]), Binding::from("users/email")), + (key_id([2u8; IV_LEN], &[8]), Binding::from("users/name")), + ]) + .await + .unwrap(); + + let request = captured.take(); + assert_eq!(request.keys.len(), 2); + assert_eq!(request.keys[0].iv.as_ref(), &[1u8; IV_LEN]); + assert_eq!(request.keys[0].tag.as_ref(), &[9, 9]); + assert_eq!(request.keys[0].descriptor, "users/email"); + assert_eq!(request.keys[1].iv.as_ref(), &[2u8; IV_LEN]); + assert_eq!(request.keys[1].tag.as_ref(), &[8]); + assert_eq!(request.keys[1].descriptor, "users/name"); + assert_eq!(request.keyset_id, Some(IdentifiedBy::Uuid(KEYSET_ID))); + assert_eq!(keys.len(), 2); + } + + #[tokio::test] + async fn generate_then_retrieve_derives_the_same_key() { + // ZeroKMS returns the same underlying material for generate and + // retrieve, so the derived data key must round-trip. + let keyset = build_keyset(|builder| { + builder + .add_success_response::(GenerateKeyResponse { + keys: vec![generated_key(vec![9, 9, 9])], + }) + .add_success_response::(RetrieveKeyResponse { + keys: vec![RetrievedKey { + key_material: key_material(), + }], + }) + }) + .await; + + let generated = keyset + .generate_keys(&[Binding::from("desc")]) + .await + .unwrap(); + + let retrieved = keyset + .retrieve_keys(&[(generated[0].key_id.clone(), Binding::from("desc"))]) + .await + .unwrap(); + + assert_eq!( + generated[0].plaintext.clone().risky_unwrap(), + retrieved[0].clone().risky_unwrap() + ); + } + + /// The other side of the length check. A key id of exactly the IV + /// length splits into an IV and an *empty* tag, which is a shape the + /// split accepts — so the guard has to be `<`, not `<=`. Without + /// this, tightening it by one byte would reject a key id the format + /// permits and no test would notice. + #[tokio::test] + async fn a_key_id_that_is_exactly_the_iv_splits_with_an_empty_tag() { + let captured = Captured::>::new(); + let keyset = build_keyset(|builder| { + builder + .add_effect::(captured.recorder()) + .add_success_response::(RetrieveKeyResponse { + keys: vec![RetrievedKey { + key_material: key_material(), + }], + }) + }) + .await; + + let keys = keyset + .retrieve_keys(&[(KeyId::new(vec![7u8; IV_LEN]), Binding::from("users/email"))]) + .await + .expect("a key id of exactly the IV length is splittable"); + assert_eq!(keys.len(), 1); + + let request = captured.take(); + assert_eq!(request.keys.len(), 1); + assert_eq!( + request.keys[0].iv.as_ref(), + &[7u8; IV_LEN], + "the whole key id is the IV" + ); + assert!( + request.keys[0].tag.is_empty(), + "and the tag is what is left, which is nothing" + ); + } + + #[tokio::test] + async fn a_short_key_id_fails_before_any_request() { + // No `retrieve-data-key` handler is stubbed, so reaching the + // connection would panic rather than return this error. + let keyset = build_keyset(|builder| builder).await; + + let Err(err) = keyset + .retrieve_keys(&[(KeyId::new(vec![0u8; IV_LEN - 1]), Binding::EMPTY)]) + .await + else { + panic!("a key id shorter than the IV cannot be split"); + }; + + assert!( + matches!( + err, + Error::MalformedKeyId { + len: 15, + min: IV_LEN + } + ), + "got: {err:?}" + ); + } + + #[tokio::test] + async fn a_non_utf8_binding_fails_before_any_request() { + let keyset = build_keyset(|builder| builder).await; + + let Err(err) = keyset + .retrieve_keys(&[(key_id([0u8; IV_LEN], &[1]), Binding::new(&[0xff, 0xfe]))]) + .await + else { + panic!("a non-UTF-8 binding cannot be a descriptor"); + }; + + assert!(matches!(err, Error::BindingNotUtf8), "got: {err:?}"); + } + } + + /// What this provider promises a caller that reads the traits rather than + /// the backend — see the module docs. + #[test] + fn declares_what_zerokms_is() { + assert_eq!( + >::RECONSTRUCTION, + KeyReconstruction::ClientAndServer + ); + assert_eq!( + >::CUSTODY, + KeyCustody::BackendHeld + ); + } +}